0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SpeechAnalyzerのタイムスタンプ付き文字起こしをTranslation frameworkで翻訳する

0
Last updated at Posted at 2026-07-05

macOSアプリで、動画の音声を文字起こしして、その結果を翻訳する機能を実装しました。

この記事では、次の2点に絞って書きます。

  • SpeechAnalyzer でタイムスタンプ付きの文字起こしSegmentを作る
  • Translation framework でSegment単位に翻訳し、元のタイムスタンプを維持する

Video Notes pipeline

単に全文テキストを翻訳するのではなく、

struct Segment: Codable, Sendable, Equatable, Identifiable {
    var id = UUID()
    var time: TimeInterval
    var text: String
}

のような形で、動画の位置へ戻れるデータとして扱うのが目的です。

前提

音声はあらかじめWAVにしてあります。

自分のアプリでは、動画を libmpv で開いて16kHz mono WAVを書き出し、そのWAVを SpeechAnalyzer に渡しています。

この記事ではWAVがある前提で、文字起こし以降だけを扱います。

let wav: URL = ...
let locale = Locale(identifier: "ja-JP")
let transcript = await SpeechPipeline.transcribe(wav, locale: locale)

SpeechAnalyzerでタイムスタンプを取る

SpeechTranscriber を作るとき、attributeOptions.audioTimeRange を指定します。

let transcriber = SpeechTranscriber(
    locale: locale,
    transcriptionOptions: [],
    reportingOptions: [],
    attributeOptions: [.audioTimeRange]
)

これを入れておくと、結果の AttributedString のrunから音声内の時間範囲を取り出せます。

初回は音声認識アセットを用意する

指定したlocaleの認識アセットが未インストールの場合があります。

if let request = try await AssetInventory
    .assetInstallationRequest(supporting: [transcriber]) {
    try await request.downloadAndInstall()
}

ここは時間がかかる可能性があるので、UIでは「準備中」や「文字起こし中」として扱える状態を持っておくとよいです。

WAVを解析する

実装は次のようにしました。

import AVFoundation
import Speech

enum SpeechPipeline {
    static func transcribe(_ wav: URL, locale: Locale) async -> [Segment] {
        do {
            let transcriber = SpeechTranscriber(
                locale: locale,
                transcriptionOptions: [],
                reportingOptions: [],
                attributeOptions: [.audioTimeRange]
            )

            if let request = try await AssetInventory
                .assetInstallationRequest(supporting: [transcriber]) {
                try await request.downloadAndInstall()
            }

            let analyzer = SpeechAnalyzer(modules: [transcriber])
            let audioFile = try AVAudioFile(forReading: wav)

            let collector = Task { () -> [Segment] in
                var segments: [Segment] = []

                for try await result in transcriber.results {
                    let text = String(result.text.characters)
                        .trimmingCharacters(in: .whitespacesAndNewlines)

                    guard !text.isEmpty else { continue }

                    segments.append(.init(
                        time: startTime(of: result.text),
                        text: text
                    ))
                }

                return segments
            }

            if let lastSample = try await analyzer.analyzeSequence(from: audioFile) {
                try await analyzer.finalizeAndFinish(through: lastSample)
            } else {
                await analyzer.cancelAndFinishNow()
            }

            return try await collector.value
        } catch {
            return []
        }
    }

    private static func startTime(of text: AttributedString) -> TimeInterval {
        for run in text.runs {
            if let range = run.audioTimeRange {
                return range.start.seconds
            }
        }
        return 0
    }
}

transcriber.results は非同期に流れてくるので、analyzeSequence を走らせながら別Taskで結果を集めています。

この結果、次のような配列を作れます。

[
    Segment(time: 4.2, text: "今日はこの実装について話します"),
    Segment(time: 17.8, text: "次に翻訳処理を見ます")
]

UI側では、各行をクリックしたら time にシークできます。

翻訳はTranslationSessionを直接newしない

翻訳で少し設計が変わるのは、TranslationSession をサービスクラスで直接作るのではなく、SwiftUIの .translationTask から受け取る点です。

そのため、Modelは「翻訳したい」という設定を作り、Viewが実際のsessionを受け取ってModelに戻す形にしました。

import Translation

@MainActor
@Observable
final class NotesModel {
    var translationConfig: TranslationSession.Configuration?

    private(set) var translatingID: UUID?
    private(set) var translationFailedID: UUID?

    private var translationSource: [Segment] = []
    private var translationInFlight = false
    private var translationPair: String?

    func translate(id: UUID, notes: Notes) {
        guard !notes.transcript.isEmpty,
              notes.translation.isEmpty else {
            return
        }

        translatingID = id
        translationFailedID = nil
        translationSource = notes.transcript

        let src = notes.transcribeLanguage == "ja" ? "ja" : "en"
        let dst = src == "ja" ? "en" : "ja"
        let pair = "\(src)>\(dst)"

        if translationConfig != nil && pair == translationPair {
            translationConfig?.invalidate()
        } else {
            translationConfig = TranslationSession.Configuration(
                source: Locale.Language(identifier: src),
                target: Locale.Language(identifier: dst)
            )
        }

        translationPair = pair
    }
}

同じ言語ペアでも再翻訳したいことがあるので、既存の translationConfig が同じpairなら invalidate() しています。

逆に、言語ペアが変わった場合は新しい TranslationSession.Configuration を作ります。ここを使い回すと、別の動画で翻訳方向が古いままになることがあります。

View側でtranslationTaskを持つ

View側は次のようにします。

struct NotesPanel: View {
    let model: NotesModel

    var body: some View {
        content
            .translationTask(model.translationConfig) { session in
                await model.performTranslation(using: session)
            }
    }
}

Model側では、渡された TranslationSession で翻訳を実行します。

@MainActor
extension NotesModel {
    func performTranslation(using session: TranslationSession) async {
        guard let id = translatingID,
              !translationInFlight else {
            return
        }

        translationInFlight = true
        defer {
            translationInFlight = false
            translatingID = nil
        }

        let segments = translationSource

        do {
            try await session.prepareTranslation()

            let requests = segments.enumerated().map {
                TranslationSession.Request(
                    sourceText: $0.element.text,
                    clientIdentifier: String($0.offset)
                )
            }

            let responses = try await session.translations(from: requests)

            let translated = zip(segments, responses).map {
                Segment(time: $0.time, text: $1.targetText)
            }

            guard !translated.isEmpty else {
                translationFailedID = id
                return
            }

            // cache[id]?.translation = translated
            translationFailedID = nil
        } catch {
            translationFailedID = id
        }
    }
}

ここで重要なのは、翻訳後も元の time を残すことです。

Segment(time: original.time, text: translatedText)

としておけば、翻訳表示に切り替えても、クリックした行から同じ動画位置へ戻れます。

複数のtranslationTaskが走るケース

同じModelを複数のViewから見せる構成では、.translationTask が複数箇所で発火することがあります。

自分の実装では、インスペクタ内のパネルと別ウィンドウのパネルが同じModelを見る可能性があったため、translationInFlight を持たせて二重実行を止めています。

guard let id = translatingID,
      !translationInFlight else {
    return
}

translationInFlight = true

このフラグは最初の await より前に立てています。そうしないと、2つの .translationTask がほぼ同時に入ったときに両方が通る可能性があります。

翻訳アセットの準備

session.prepareTranslation() を明示的に呼んでいます。

try await session.prepareTranslation()

これを入れずに翻訳を始めると、必要な言語アセットがない環境で失敗し、UI上は単に翻訳が空のままに見えます。

失敗時は translationFailedID のような状態を持って、ユーザーに「翻訳できなかった」ことを出せるようにしています。

まとめ

動画や音声の文字起こし結果をアプリ内で使うなら、全文文字列ではなくSegment配列にしておくと扱いやすいです。

struct Segment {
    var time: TimeInterval
    var text: String
}

実装上のポイントは次の通りです。

  • SpeechTranscriber.audioTimeRange を指定する
  • AttributedString のrunから開始時刻を取り出す
  • 翻訳は .translationTask から TranslationSession を受け取る
  • 翻訳後も元のSegmentの time を維持する
  • 同じModelを複数Viewで使うなら二重実行を防ぐ

この形にすると、文字起こし表示と翻訳表示のどちらでも、行クリックで動画へシークできるUIを作れます。


関連記事:

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?