Speech Framework + Translation Frameworkで完全オンデバイスの英語字幕自動生成アプリを作った
iPhone/iPad内の英語動画から、サーバーを一切使わずに英語字幕と日本語字幕を自動生成するアプリを個人開発しました。この記事では、Apple純正のSpeech FrameworkとTranslation Frameworkを使って完全オンデバイスの字幕生成パイプラインを構築した際の技術的な知見を共有します。
この記事で分かること
- Speech Frameworkでオンデバイス音声認識を行う際の設定と注意点
-
AsyncThrowingStreamを使った音声認識結果のストリーミング処理 - 音声認識結果のチャンク結合とセグメント分割のロジック
- Translation Frameworkのセッション管理とバッチ翻訳の実装
-
TranslationSessionの無効化(invalidate)問題への対処法 - 部分結果の
timestamp=0問題の原因と回避策 - 「再生しながら字幕を追いかけ生成する」ストリーミングパイプラインの設計
技術スタック概要
今回使ったApple純正フレームワークは以下の2つです。
Speech Framework(音声認識)
SFSpeechRecognizerを使って、動画の音声トラックから英語テキストを生成します。iOS 10から利用可能ですが、iOS 17以降ではオンデバイス認識の精度が大幅に向上しており、英語であれば実用レベルの結果が得られます。
Translation Framework(翻訳)
iOS 17.4で導入された新しいフレームワークです。TranslationSessionを使って、英語テキストを日本語に翻訳します。こちらも完全オンデバイスで動作し、言語パックをダウンロードすればオフラインでも利用可能です。
どちらもサーバーへの通信が不要なので、ユーザーの動画データがデバイス外に出ることはありません。英語学習アプリでは動画コンテンツのプライバシーが重要なので、この点は設計上の大きなメリットです。
Speech Frameworkで音声認識
オンデバイス優先の設定
音声認識の実装で最初に気をつけるべきは、オンデバイス認識の明示的な有効化です。
let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))
let request = SFSpeechURLRecognitionRequest(url: url)
request.shouldReportPartialResults = true
request.addsPunctuation = true
// オンデバイス認識が利用可能なら強制的にオンデバイスを使う
if recognizer.supportsOnDeviceRecognition {
request.requiresOnDeviceRecognition = true
}
requiresOnDeviceRecognition = trueを設定しないと、デフォルトではAppleのサーバーに音声データが送信されます。supportsOnDeviceRecognitionで対応状況を確認してから設定することで、オンデバイス非対応のデバイスではサーバー認識にフォールバックさせています。
SFSpeechURLRecognitionRequestはファイルURLを渡すだけで音声トラックを自動抽出してくれるので、動画ファイルからの認識には最も手軽な選択肢です。
AsyncThrowingStreamでのストリーミング処理
Speech FrameworkのrecognitionTaskはコールバックベースのAPIですが、これをAsyncThrowingStreamでラップすることで、Swift Concurrencyの世界に持ち込んでいます。
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
if let error {
// エラー時でも蓄積済みの結果があれば返す
if let current = currentBest {
allTranscriptions.append(current)
}
let segments = Self.buildSegmentsFromMultiple(allTranscriptions)
if !segments.isEmpty {
for segment in segments {
continuation.yield(segment)
}
continuation.finish()
} else {
continuation.finish(throwing: RecognitionError.recognitionFailed(error.localizedDescription))
}
return
}
guard let result else { return }
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()
}
}
}
}
ここでのポイントは、コールバックが呼ばれるたびにcontinuation.yield()するのではなく、isFinalになるまで結果を蓄積している点です。この理由は後述する「timestamp=0問題」に関係します。
チャンク結合のロジック
Speech Frameworkは長い音声を内部的にチャンクに分割して処理します。チャンクの切り替わりは、bestTranscription.segmentsの数が突然減ることで検出できます。
if let prev = currentBest,
transcription.segments.count < prev.segments.count {
// 前のチャンクの結果を保存し、新チャンクの追跡を開始
allTranscriptions.append(prev)
currentBest = transcription
} else {
currentBest = transcription
}
各チャンクの最良の結果をallTranscriptionsに蓄積しておき、最後に結合します。
音声認識結果のセグメント分割
Speech FrameworkのSFTranscriptionSegmentは単語単位なので、これを「字幕の1行」としてまとめる処理が必要です。分割の基準は3つあります。
private static func buildSegments(from transcription: SFTranscription) -> [SubtitleSegment] {
let sfSegments = transcription.segments
var result: [SubtitleSegment] = []
var currentWords: [SFTranscriptionSegment] = []
let gapThreshold: TimeInterval = 1.5
for (i, seg) in sfSegments.enumerated() {
currentWords.append(seg)
let isLast = i == sfSegments.count - 1
let hasGap: Bool
if !isLast {
let nextStart = sfSegments[i + 1].timestamp
let currentEnd = seg.timestamp + seg.duration
hasGap = (nextStart - currentEnd) > gapThreshold
} else {
hasGap = false
}
let endsWithPunctuation = seg.substring.hasSuffix(".")
|| seg.substring.hasSuffix("?")
|| seg.substring.hasSuffix("!")
let isTooLong = currentWords.count >= 15
if isLast || hasGap || endsWithPunctuation || isTooLong {
let text = currentWords.map(\.substring).joined(separator: " ")
let startTime = currentWords.first!.timestamp
let lastWord = currentWords.last!
let endTime = lastWord.timestamp + lastWord.duration
let segment = SubtitleSegment(
startTime: startTime,
endTime: endTime,
englishText: text,
words: currentWords.map { SubtitleWord(text: $0.substring) }
)
result.append(segment)
currentWords = []
}
}
return result
}
分割条件をまとめると以下のようになります。
- 句読点(
.?!)で終わる → 文の終わり - 次の単語との間に1.5秒以上の無音区間がある → 発話の切れ目
- 15単語を超えた → 字幕表示として長すぎるので強制分割
この3つを組み合わせることで、自然な字幕単位が得られます。addsPunctuation = trueを設定しておくと、Speech Frameworkが句読点を自動付与してくれるので、文の区切りを判定しやすくなります。
Translation Frameworkで翻訳
セッション管理
Translation FrameworkはSwiftUIの.translationTaskモディファイアと連携する設計になっています。TranslationSessionはView側から渡す必要があり、サービス層で自前に生成することはできません。
@Observable
final class TranslationService {
private var session: TranslationSession?
private(set) var translationConfiguration = TranslationSession.Configuration(
source: Locale.Language(identifier: "en"),
target: Locale.Language(identifier: "ja")
)
func setSession(_ session: TranslationSession) {
self.session = session
}
func invalidateSession() {
session = nil
translationConfiguration.invalidate()
}
}
View側では.translationTaskを使ってセッションを渡します。
.translationTask(translationService.translationConfiguration) { session in
translationService.setSession(session)
}
translationConfiguration.invalidate()を呼ぶと.translationTaskが再トリガーされ、新しいセッションが発行されます。この仕組みは後述するinvalidate問題で重要になります。
バッチ翻訳
翻訳はセグメント単位でバッチ処理します。一度に大量のリクエストを投げるとTranslation Frameworkが不安定になるため、10件ずつに制限しています。
private let maxBatchSize = 10
func translate(segments: [SubtitleSegment]) async -> [(Int, String)] {
guard let session else { return [] }
let untranslated = segments.enumerated().compactMap { index, seg -> (Int, String)? in
guard seg.japaneseText == nil else { return nil }
let trimmed = seg.englishText.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : (index, trimmed)
}
guard !untranslated.isEmpty else { return [] }
let batch = Array(untranslated.prefix(maxBatchSize))
let requests = batch.map {
TranslationSession.Request(sourceText: $0.1, clientIdentifier: "\($0.0)")
}
let responses = try await session.translations(from: requests)
return responses.compactMap { response in
guard let idStr = response.clientIdentifier,
let index = Int(idStr) else { return nil }
return (index, response.targetText)
}
}
clientIdentifierにセグメントのインデックスを文字列で渡しておくと、レスポンスとの対応付けが簡単にできます。Translation Frameworkはレスポンスの順序を保証していないため、このIDベースのマッチングは必須です。
ストリーミング字幕生成パイプライン
ユーザー体験として最も重視したのが「動画を選んだら即再生が始まり、字幕は後から追いついてくる」というストリーミング生成です。字幕生成が完了するまで待たされるのでは、学習のテンポが崩れます。
パイプラインの全体像
func loadVideo(url: URL) {
// 1. AVPlayerに動画を即セット → 再生開始
let item = AVPlayerItem(asset: AVURLAsset(url: url))
player.replaceCurrentItem(with: item)
// 2. バックグラウンドで字幕生成を開始
Task {
let cmDuration = try await asset.load(.duration)
self.duration = cmDuration.seconds.isFinite ? cmDuration.seconds : 0
self.startStreamingRecognition(for: url)
}
}
動画の読み込みと字幕生成は並行して走ります。AVPlayerのreplaceCurrentItemは即座に再生準備を始めるので、字幕生成の完了を待つ必要はありません。
認識 → 翻訳の連鎖
startStreamingRecognitionの中で、音声認識と翻訳を直列につないでいます。
private func startStreamingRecognition(for url: URL) {
subtitleProgress = "音声解析中..."
recognitionTask = Task {
// 1. 音声認識(ストリームで結果を受け取る)
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)
}
}
// 2. 認識完了 → 翻訳を小バッチで繰り返し実行
let totalSegments = self.subtitleSegments.count
self.subtitleProgress = "翻訳中(0/\(totalSegments))..."
while self.subtitleSegments.contains(where: { $0.japaneseText == nil }) {
guard self.videoURL == url else { return }
await self.translateUntranslatedSegments(for: url)
let translated = self.subtitleSegments.filter { $0.japaneseText != nil }.count
self.subtitleProgress = "翻訳中(\(translated)/\(totalSegments))..."
}
self.subtitleProgress = nil
}
}
翻訳は10件ずつのバッチを繰り返し実行するwhileループで処理しています。こうすることで、翻訳が進むたびにUIに進捗が反映され、「翻訳中(15/42)...」のような表示がリアルタイムに更新されます。
動画切り替え時のガード
guard self.videoURL == url else { return }を各所に入れているのは、ユーザーが字幕生成中に別の動画を選んだ場合に、古い動画の処理を安全に中断するためです。TaskのキャンセルだけではAsyncThrowingStreamの内部コールバックが即座に止まらないため、URLの一致チェックを併用しています。
ハマったポイントと解決策
timestamp=0問題
Speech Frameworkのオンデバイス認識で最もハマったのが、部分結果(isFinal == false)のタイムスタンプがすべて0になる問題です。
shouldReportPartialResults = trueを設定すると、認識が進むたびにコールバックが呼ばれます。しかし、オンデバイス認識では部分結果のSFTranscriptionSegment.timestampが0のまま更新されません。タイムスタンプが正しい値になるのはisFinal == trueのときだけです。
字幕アプリではタイムスタンプが動画の再生位置と対応している必要があるので、部分結果をそのまま使うわけにはいきません。そこで、isFinalが来るまですべての結果を蓄積しておき、最後にまとめてセグメントを構築するという方針を取りました。
さらに、チャンクの切り替わり時に中間結果が混入すると、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なので、これで誤って消されることはありません。
TranslationSessionの無効化問題
Translation FrameworkのTranslationSessionは、一定時間経過やメモリ圧迫時に内部的に無効化されることがあります。無効化されたセッションでtranslations(from:)を呼ぶとエラーが返ります。
対策として、翻訳失敗時にセッションを再作成する仕組みを用意しています。
func invalidateSession() {
session = nil
translationConfiguration.invalidate()
}
TranslationSession.Configurationのinvalidate()を呼ぶと、SwiftUI側の.translationTaskが再トリガーされて新しいセッションが発行されます。この新しいセッションをsetSession()で受け取れば、翻訳処理を再開できます。
Translation Frameworkはまだ新しいフレームワークということもあり、ドキュメントに書かれていない挙動がいくつかあります。バッチサイズを小さく保つ(今回は10件)ことと、セッションの再作成ルートを確保しておくことが安定運用のコツです。
オンデバイス認識でisFinal時に空結果が返る問題
まれに、isFinal == trueのコールバックでbestTranscription.formattedStringが空になることがあります。これはオンデバイス認識のバグと考えられます。
対策として、エラー発生時でも蓄積済みの部分結果があればそれを使って字幕を構築するフォールバックを入れています。
if let error {
if let current = currentBest {
allTranscriptions.append(current)
}
let segments = Self.buildSegmentsFromMultiple(allTranscriptions)
if !segments.isEmpty {
// エラーでも結果があれば返す
for segment in segments {
continuation.yield(segment)
}
continuation.finish()
} else {
continuation.finish(throwing: RecognitionError.recognitionFailed(error.localizedDescription))
}
return
}
完全な失敗と部分的な成功を区別することで、ユーザーに「字幕が全く出ない」状況をできるだけ避けています。
まとめ
オンデバイス処理のメリット
- サーバーコストがゼロ。個人開発にとってこれは大きい
- ユーザーの動画データがデバイス外に出ないので、プライバシーの説明が簡潔になる
- オフライン環境でも動作する(言語パックのダウンロードは初回のみ)
- レイテンシがネットワーク状況に依存しない
オンデバイス処理のデメリット
- Speech Frameworkの認識精度はWhisper等のサーバーモデルに劣る。特に雑音の多い動画や非ネイティブの英語では差が顕著
- オンデバイス認識特有のバグ(timestamp=0、空結果)への対処が必要
- Translation Frameworkはまだ成熟途上で、セッション管理に不安定な面がある
- デバイスの処理能力に依存するため、古いデバイスでは処理時間が長くなる
とはいえ、英語学習アプリという用途では「クリアな発音の教材動画」が主なターゲットなので、Speech Frameworkの認識精度で実用上十分な結果が得られています。Apple純正フレームワークだけでここまでできるというのは、個人開発者にとって心強い選択肢です。
アプリについて
この記事で解説した技術を使って「Veloquo」というiOS/iPadOSアプリを作りました。iPhone/iPad内の英語動画を読み込んで、二重字幕(英語+日本語)で学習できるアプリです。単語タップ辞書や1文ループ再生など、英語学習に特化した機能も搭載しています。
App Storeで公開中です。興味があればぜひ試してみてください。
実際の使い方
ユーザー向けの使い方ガイドも用意しています。
