asken テックブログ

askenエンジニアが日々どんなことに取り組み、どんな「学び」を得ているか、よもやま話も織り交ぜつつ綴っていきます。 皆さまにも一緒に学びを楽しんでいただけたら幸いです! <br> 食事管理アプリ『あすけん』 について <br> https://www.asken.jp/ <br>

iOS26 から使える SpeechAnalyzer と SpeechRecognizer の比較

はじめに

この記事は、株式会社asken (あすけん) Advent Calendar 2025 の5日目の記事です。

こんにちは!あすけんで iOS アプリエンジニアをしています、髙橋です。 早いものであすけんに入社してから2ヶ月があっという間に過ぎ、もうクリスマスの季節かと時間の早さに驚いています。 このままあっという間にクリスマス、正月になって新しい年が始まるのかなと日々の忙しさの中でぼんやりと考えていました。

さて、今回は音声認識について調べる機会があったので iOS26 から新しく追加された SpeechAnalyzerSFSpeechRecognizer と比較しつつまとめたのでこちらに書いていければと思います! SFSpeechRecognizerSpeechAnalyzer を比較したサンプルプロジェクトもありますのでこちらも合わせてご覧ください!

github.com

SpeechAnalyzer とは?

概要と構造

SpeechAnalyzer は iOS26 以降で Speech フレームワークに追加された新しい音声認識の API です。

developer.apple.com

SpeechAnalyzer 自体は音声認識の窓口となるクラスで、音声認識を一つのセッションとして管理し、音声認識の開始と終了を行ったりマイクなどから音声データを入力するクラスです。 実際の文字起こしなどは「モジュール」と呼ばれる関連クラスが行います。 例えば入力された音声データを解析してテキストデータに変換する場合は SpeechTranscriber というモジュールを SpeechAnalyzer に追加することで実現できます。

モジュールについて

SpeechAnalyzer で利用するモジュールは SpeechModule というプロトコルに準拠する必要があります。 現在 Apple から提供されているモジュールは以下の三つです

  • SpeechTranscriber
  • SpeechDetector
  • DictationTranscriber

この三つについてざっと解説していきます

SpeechTranscriber

最も利用されるであろうモジュールで主なユースケースである、文字起こしの機能を担当します。 ユーザーがマイクに入力した音声を文字として入力したい、音声ファイルなどからテキストの文字起こしを行いたいという場合にはこのモジュールを利用します。 Apple の最新の変換モデルを採用されており、遠くの音声でも拾うことができたり、長時間の音声の文字起こしにも対応できたりととてもパワーアップしています。 非常に便利なクラスですが、注意点としてオンデバイスで動作するため、端末の性能が重要になります。 つまり、OS バージョンだけでなく、端末によってもサポートしているか否かが別れます。 新しい音声モデルをサポートしている端末か isAvailable でチェックすることができるのでそちらを利用して分岐をさせる必要があります。

DictationTranscriber

こちらもそこそこ利用されることがあるのではないかなと思います。 先ほど記載した通り、SpeechTranscriber に対応していない端末もあります。 そういった端末で利用するのがこの DictationTranscriber で、性能は SFSpeechRecognizer と同程度ということです。 もし iOS26 しかサポートしなくても良いという稀有なプロジェクトであれば、 SFSpeechRecognizer の代わりにこちらを利用するだけで済みそうです。

SpeechDetector

こちらは上記二つとは少し毛色が異なるモジュールで、入力された音声データの中に「音声が存在するか?」を検出してくれます。 性質上単独ではなく、音声認識の補助のモジュールとして上記の SpeechTranscriber などと組み合わせて使用されます。 音声がない入力に対して文字起こしすることを避けることで、高度な文字起こしを利用しつつ、電力消費を抑えるといった実装が可能になります。

両者の実装の違い

SpeechAnalyzer について説明したところで、実装面での違いについてお話しできればと思います。 SFSpeechRecognizer の実装についてはご存知の方や解説記事も多く出ているため割愛させていただきます。 次のセクションでコードを見つつ解説しますが、全体像を把握できるように簡単な図にしてみました。

この流れの中で SpeechAnalyzerSFSpeechRecognizer と違う点として重要なものは以下2点かなと思います。

  • 音声認識の権限が必要ない
  • 入力された音声を SpeechAnalyzer 用に変換する必要がある

SpeechAnalyzer の実装

では順を追って実装の要点を見ていきましょう。

1. マイクの権限を取得

SFSpeechRecognizer と同じく、マイクの権限を取得します。 先ほどもちらっと述べましたが、SpeechAnalyzer は音声認識の権限は必要ありません。 実装もコード量が少なくて済むというのもありますが、権限取得のダイアログを二回表示しなくて良いというのはアプリ側としても、ユーザーとしても嬉しい点かなと思います。

    func requestRecordPermission() async -> Bool {
        return await withCheckedContinuation { continuation in
            AVAudioApplication.requestRecordPermission { granted in
                continuation.resume(returning: granted)
            }
        }
    }

2. AudioSession/AudioEngine の準備

音声入力を行うために、AVAudioSessionAVAudioEngine を準備します。 AVAudioSession は音声セッションの設定を行い、AVAudioEngine はマイクからの音声データを取得するために使用します。

func activateAudioSession() async throws {
    try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers)
    try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
}

次に、AVAudioEngineinputNode にタップをインストールし、マイクから取得した音声バッファを AsyncStream を通じて非同期に処理できるようにします。 取得した音声バッファは、後のステップで SpeechAnalyzer が要求するフォーマットに変換した後、AnalyzerInput として inputBuilder.yield() で渡します。

func audioBufferStream() async throws -> AsyncStream<AVAudioPCMBuffer> {
    let inputNode = audioEngine.inputNode
    let recordingFormat = inputNode.inputFormat(forBus: 0)
    
    inputNode.installTap(onBus: 0, bufferSize: 2048, format: recordingFormat) { [weak self] buffer, _ in
        guard let self = self else { return }
        self.audioBufferContinuation?.yield(buffer)
    }
    
    audioEngine.prepare()
    try audioEngine.start()
    
    return AsyncStream(AVAudioPCMBuffer.self, bufferingPolicy: .unbounded) { continuation in
        self.audioBufferContinuation = continuation
    }
}

3. SpeechAnalyzer の準備

SpeechAnalyzer のインスタンスを作成します。 初期化時に使用するモジュール(この場合は SpeechTranscriber)を配列で渡します。 SpeechTranscriber の初期化時に渡しているオプションについて、ざっくりとした概要は次のとおりです。

transcriptionOptions
文字起こしの動作を制御するオプションです。現在は etiquetteReplacements のみ存在しており特定の単語やフレーズを伏せ字形式に置き換えることができます。今回は指定していませんが、実際のプロジェクトに組み込む際には指定を検討したいです。

reportingOptions: [.volatileResults]
認識結果の報告方法を制御するオプションです。.volatileResults を指定することで、確定前の暫定結果も取得できるようになります。ユーザーが話している最中でもリアルタイムでテキストを表示でき、確定したテキストと暫定のテキストを区別して扱うことができます。

attributeOptions: [.audioTimeRange]
認識結果に含める属性情報を指定するオプションです。.audioTimeRange を指定することで、認識されたテキストが元の音声のどの時間範囲に対応しているかの情報が含まれます。例えば長時間の文字起こしなどで、テキストが起こされた箇所に飛ばして再生するといったこともできそうです。

let speechTranscriber = SpeechTranscriber(locale: Locale.ja,
                                          transcriptionOptions: [],
                                          reportingOptions: [.volatileResults],
                                          attributeOptions: [.audioTimeRange])

self.speechTranscriber = speechTranscriber
self.speechAnalyzer = SpeechAnalyzer(modules: [speechTranscriber])

4. SpeechTranscriber の準備

SpeechTranscriber の設定を行います。 bestAvailableAudioFormat を使用することで、SpeechTranscriber に最適な音声フォーマットを取得できます。 また、SpeechTranscriber を利用する際は言語に応じたモデルをインストールする必要があります。 今回は ensureModel で使用する言語のモデルがインストールされているか確認し、必要に応じてダウンロードします。

func setupAnalyzer() async throws {
    self.analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [speechTranscriber])
    
    try await ensureModel(transcriber: speechTranscriber, locale: Locale.ja)
    
    (inputSequence, inputBuilder) = AsyncStream<AnalyzerInput>.makeStream()
    
    recognitionTask = Task {
        do {
            for try await case let result in speechTranscriber.results {
                let text = result.text
                
                if result.isFinal {
                    finalizedText += text
                    volatileText = ""
                } else {
                    volatileText = text
                    volatileText.foregroundColor = .white.opacity(0.8)
                }
            }
        } catch {
            print("speech recognition failed")
        }
    }
}

AsyncStream<AnalyzerInput> を作成し、これを通じて音声データを SpeechAnalyzer に渡します。 また、speechTranscriber.results を監視して、認識結果を取得するタスクも作成しています。

5. 音声認識の開始

準備が整ったら、speechAnalyzer.start() を呼び出して音声認識を開始します。 この際、先ほど作成した AsyncStream<AnalyzerInput> を渡します。

try await speechAnalyzer.start(inputSequence: inputSequence)

6. 入力された音声をSpeechAnalyzer 用に変換

SFSpeechRecognizer では入力された音声の buffer をそのまま渡していましたが、SpeechAnalyzer が要求するフォーマットに変換し、変換した buffer を AnalyzerInput として渡す必要があります。 サンプル内では BufferConverter というクラスがその役割を担っていますがこの辺は全くの素人のため、 WWDC で利用されていたコードをそのまま持ってきています。 サンプルレートなどを合わせているのかなとは分かりますが、雰囲気しか理解していないため、実際のプロジェクトで利用する際にはこの辺の変換処理もきちんと理解した上で利用しないといけないなと考えています。

出典: developer.apple.com

class BufferConverter {
    private var converter: AVAudioConverter?
    
    func convertBuffer(_ buffer: AVAudioPCMBuffer, to format: AVAudioFormat) throws -> AVAudioPCMBuffer {
        let inputFormat = buffer.format
        guard inputFormat != format else {
            return buffer
        }
        
        if converter == nil || converter?.outputFormat != format {
            converter = AVAudioConverter(from: inputFormat, to: format)
            converter?.primeMethod = .none
        }
        
        // ... 変換処理 ...
        
        return conversionBuffer
    }
}

呼び出しは以下のようになります。

for await buffer in try await audioBufferStream() {
    guard let analyzerFormat else {
        throw TranscriptionError.invalidAudioDataType
    }
    
    let converted = try bufferConverter.convertBuffer(buffer, to: analyzerFormat)
    let input = AnalyzerInput(buffer: converted)
    inputBuilder.yield(input)
}

7. テキストへ変換

SpeechTranscriber が音声データをテキストに変換すると、speechTranscriber.results を通じて結果が取得できます。 結果には isFinal プロパティがあり、これが true の場合は確定したテキスト、false の場合は暫定のテキストとして扱います。

for try await case let result in speechTranscriber.results {
    let text = result.text
    
    if result.isFinal {
        finalizedText += text
        volatileText = ""
    } else {
        volatileText = text
        volatileText.foregroundColor = .white.opacity(0.8)
    }
}

確定したテキストは finalizedText に追加し、暫定のテキストは volatileText に設定して、UI で区別して表示できるようにしています。

8. 音声入力の停止

最後に音声認識を停止する処理です。停止処理は以下の順序で処理を行います。

  1. AudioEngine を停止し、タップを削除
  2. AudioSession を非アクティブにする
  3. inputBuilder.finish() で入力ストリームを終了
  4. speechAnalyzer.finalizeAndFinishThroughEndOfInput()SpeechAnalyzer に終了を通知
  5. 認識タスクをキャンセル
func stopAnalyzer() {
    Task {
        do {
            stopAudioEngine()
            
            try deactivateAudioSession()
            
            inputBuilder?.finish()
            
            try await speechAnalyzer.finalizeAndFinishThroughEndOfInput()
            
            recognitionTask?.cancel()
            recognitionTask = nil
            
            await MainActor.run {
                isRecording = false
            }
        } catch {
            print("stop failure: \(error)")
        }
    }
}

SpeechAnalyzer の良さそうな点

isFinal が便利そう

SpeechAnalyzer では isFinal を使うことで暫定の結果か、確定した結果かを判別できます。 これを利用して、仮認識中のテキストは薄い色で表示し、確定したテキストは濃い色で表示して違いを明確にしたり、キーボードでの手入力の文字がある場合にそれを上書きせず確定した結果だけを追加していくなど、ユーザー体験の向上が見込めそうです。 ちなみに isFinal フラグ自体は SFSpeechRecognizer にも存在していたのですが、recognitionTask を終了しないと isFinaltrue にならずリアルタイムで確定した結果だけをTextなどのコンポーネントに追加していくという実装が厳しい状態でした。

権限取得のダイアログを減らせる

アプリを利用する際に、さまざまな権限を要求されるのは必要だとわかっていても不安になる方もいらっしゃるのかなと感じています。 少し前から定期的にシステムからアプリの権限の見直しのダイアログを出すなどユーザーのプライバシーに関わる権限の取得は厳しくなっている印象です。 そういった観点から権限取得のダイアログの数を抑えられるのは地味ながら嬉しい点かなと感じています。

SpeechAnalyzer の気になる点

実装が少々複雑に

SFSpeechRecognizer と比較すると高機能になった分、実装も増えて複雑になっている印象です。 特に音声フォーマットの変換部分は初見では全く分からず、今も雰囲気でしか理解できていないため、SFSpeechRecognizer と比べるとハードルが少し高いなと感じてしまいました。 ただ、この辺りは AI を活用するなどして穴埋めできると思うのでそこまで大きな課題ではなさそうです。

認識が遅いことがある

私の実装方法が良くないのか、端末の性能の問題なのか、iPhone 14 Pro で試した際にはとても処理に時間がかかっていました。iPhone 14 Pro では isAvailabletrue が返っていましたが、SFSpeechRecognizer の方が処理自体は早かったです。 実装の問題なら良いのですが isAvailabletrue だけど実際に使ってみると端末の性能が十分とはいえず、処理に時間がかかるという場合は素直に分岐できなくなってしまうので、導入時の課題の一つとして注意してみなければいけない点かなと考えています。

SpeechAnalyzer と SFSpeechRecognizer の比較(どちらもボタン押下直後から話しています)

まとめ

SFSpeechRecognizer をそんなに苦労なく使えたので SpeechAnalyzer も同じ感じで行けるかなと思っていましたが、意外と苦労した点も多く、事前に調べてみてよかったなと感じました。 また、動作についてiPhone 14 Pro も4世代ほど前の機種(!)なので古めではあるのですが、端末性能が低いと感じたことはなかったので SpeechAnalyzer の解析結果が遅いことも驚きましたし、実際に動かしてみて良かったなと感じています。 利用にあたって気になるところもありますが、機能としては非常に強力で魅力的なので今後機会があればもう少し深く検証して前向きに利用を検討したいなと考えています!

採用について

askenでは、様々な技術要素に対してチャレンジしたいエンジニアを絶賛募集中です。

まずはカジュアルにお話しできればと思いますので、ぜひお気軽にご連絡ください!

https://hrmos.co/pages/asken/jobs

asken techのXアカウントで、askenのテックブログやイベント情報など、エンジニアリングに関する最新情報を発信していますので、ぜひフォローをお願いします!

参考

developer.apple.com

developer.apple.com