
-
SpeechAnalyzer:高度な音声テキスト変換をアプリに追加
新しいSpeechAnalyzer APIによる音声テキスト変換について説明します。メモ、ボイスメモ、ジャーナルなどの機能をパワーアップするSwift APIとその機能について学びましょう。音声をテキストに変換する仕組みを紹介し、エキサイティングで高性能な機能を実現するSpeechAnalyzerとSpeechTranscriberについて詳しく解説します。Code Alongでは、SpeechAnalyzerとライブトランスクリプションをアプリに取り入れる方法を紹介します。
関連する章
- 0:00 - イントロダクション
- 2:41 - SpeechAnalyzer API
- 7:03 - SpeechTranscriberモデル
- 9:06 - 音声テキスト変換機能の構築
リソース
関連ビデオ
WWDC23
-
このビデオを検索
こんにちは Speechフレームワークチームの エンジニア Donovanです Notesチームのエンジニア Shantiniです 今年は音声テキスト変換のAPIと テクノロジーの次なる進化形である SpeechAnalyzerを皆さんに ご紹介できることを嬉しく思います このセッションでは SpeechAnalyzer APIとその最も重要な 概念について説明します また このAPIを支えるモデルの 新機能についても 簡単にご紹介します 最後に このAPIの使用方法を ライブコーディングデモでお見せします SpeechAnalyzerは既に多くのシステム アプリの機能に利用されています メモ ボイスメモ ジャーナルなどです
SpeechAnalyzerをApple Intelligenceと 組み合わせることで 通話の要約など 非常に強力な機能を作成できます 後ほど このAPIを使って 独自のライブ文字起こし機能を 構築する方法を実演します その前に Donovanが新しい SpeechAnalyzer APIの概要を説明します 音声テキスト変換は 自動音声認識(ASR)とも呼ばれ 優れたユーザー体験を提供できる 汎用性の高いテクノロジーです ライブまたは録音の音声を テキスト形式に変換して デバイスが容易に表示 解釈できるようにします アプリはそのテキストをリアルタイムで 保存 検索 送信できるほか テキストベースの大規模言語モデルに 渡すこともできます
iOS 10では SFSpeechRecognizerが 導入されました このクラスによってSiriを強化する音声 テキスト変換モデルが利用可能になりました 短い音声入力では高い精度で動作し リソースに制約のあるデバイスでも Appleのサーバを活用できました しかし 一部のユースケースには 期待されたほど十分に対応できず また言語の追加もユーザーに依存していました そこで iOS 26では全プラットフォーム 向けに新しいAPIを導入します SpeechAnalyzerは 幅広いユースケースに 対応し高性能な処理を可能にします 新しいAPIはSwiftの機能を活用して 音声テキスト変換処理を実行します またユーザーのデバイスで モデルのアセットを管理でき わずかなコードで実装できます このAPIに加えて Appleは新しい 音声テキスト変換モデルを提供しており これは既にさまざまなプラットフォームで アプリの機能強化に利用されています この新しいモデルは 従来のSFSpeechRecognizerを 通じて利用可能だったモデルよりも 高速かつ柔軟に動作します このモデルは講義や会議 会話などの 長時間の音声や 遠くの音声にも適しています これらの改善により Appleは この新しいモデル(と新しいAPI)を 先ほど紹介したメモなどのアプリで 使用しています これらの新機能を活用することで 独自のアプリを構築して メモなどのApple標準アプリと 同様の音声テキスト変換機能を 実装できます まず このAPIの設計を 確認しましょう このAPIはSpeechAnalyzerクラスと 他のいくつかのクラスで構成されています SpeechAnalyzerクラスは 分析セッションを管理します セッションに「module」 クラスを追加して 特定の種類の分析を実行できます セッションに文字起こしモジュールを 追加すると 文字起こしセッションになり 音声テキスト変換処理を実行できます 音声のバッファを アナライザーインスタンスに渡すと 文字起こしモジュールを介して ルーティングされ 音声テキスト変換モデルで処理されます このモデルは音声と一致する テキストを予測し そのテキストをメタデータと共に アプリに返します
この処理はすべて非同期で実行されます アプリは利用可能になった音声を 1つのタスクで追加し その結果を別のタスクで独立して 表示またはさらに処理できます Swiftの非同期シーケンスは入力と結果を バッファリングし処理を分離します
WWDC21の「Meet AsyncSequence」 セッションで 入力シーケンスの提供や結果シーケンスの 読み取りの方法を説明しています
入力と結果を関連付けるために APIは対応する音声の タイムコードを使用します 実際すべてのAPI操作は音声タイムラインの タイムコードを使用してスケジュールされ 呼び出しの順序にかかわらず 処理の順序は予測可能になります タイムコードは個々のオーディオサンプルの レベルまで精密に設定されています 文字起こしモジュールは順番に 結果を出力して それぞれの範囲をカバーしながら 重複しないようにしています これは通常の処理です ただし オプション機能として 特定のオーディオの範囲内で文字起こしを 反復的に行うことができます この処理によって より即時性の高いフィードバックを アプリのUIで提供できます 大まかな結果をすぐに表示した後 次の数秒間で より精度の高い結果を 表示できます すぐに表示される大まかな結果を 「暫定結果」と呼びます これらの結果は発話とほぼ同時に 出力されますが 精度の低い推測です ただし このモデルは 追加の音声とコンテキストを 取得することで精度を向上させます 最終的に 結果は 可能な限り最良の精度になり 文字起こしモジュールは 最後の「確定結果」を出力します 一度確定結果を出力すると 文字起こし モジュールはこの音声範囲に対する 処理を終了して 次の範囲に進みます タイムコードを見ると 後から改善された結果が 以前の結果を 置き換えていることがわかります これは暫定結果を 有効にした場合にのみ発生します 通常 文字起こしモジュールは 確定結果のみを出力し 以前の結果を置き換えることはありません ファイルを読み込んで 文字起こしの結果を返すだけであれば 1つの関数だけで文字起こし機能を 構築できます この処理では暫定結果の処理や 多くの並行処理は必要ありません これが関数です ここでは文字起こしモジュールを作成します 文字起こしの対象となる ロケールを指定します まだ結果はありませんが 逐次データを読み取って AsyncSequence版の"reduce"を使用して 連結していきます この処理は"async let"を使用して バックグラウンドで行います ここでは アナライザーを作成し 文字起こしモジュールを追加しています 次にファイルの分析を開始します analyzeSequenceメソッドは ファイルから音声を読み取り その音声を入力シーケンスに追加します ファイルが読み取りが完了したら アナライザーに終了を指示します 追加の音声処理を行う予定が ないからです 最後に バックグラウンドで生成していた 文字起こしの結果を 返します これはファイル内の発話の内容であり 単一の属性付き文字列の形式で提供されます これで完了です
ここまでAPIの概念と 基本的な使い方を説明しました 分析セッションに文字起こしを 実行するモジュールを追加しています これは同時および非同期に動作し 音声入力と出力結果の処理を 切り離しています 音声 結果 その他の操作は セッションの音声タイムラインを使用して 関連付けます これらの結果の一部は 必要に応じて 暫定結果として提供され 残りは確定結果であり変更されません また1つの関数のユースケースで各要素が どのように連携しているかを示しました 後ほど Shantiniが1つの関数で 行っていた処理を複数のビュー モデル ビューモデルに 拡張する方法を実演します ShantiniはSpeechAnalyzerクラスや Transcriberクラスのいくつかの メソッドやプロパティを紹介し 一般的なニーズを対処する方法を説明します これらについては ドキュメントでも確認できます ここからは SpeechTranscriberクラスの 新しい音声テキスト変換モデルが持つ メリットについて説明します SpeechTranscriberはAppleによって 設計された最新のモデルによって強化され 幅広いユースケースに対応しています 私たちは長時間の音声や会話形式の ユースケースに対応できるモデルを 開発したいと考えました 会議の録音のように一部の話者が マイクの近くにいない場合にも 対応できるようにしています また低遅延が求められる ライブでの文字起こし体験を 実現しながら 精度や可読性を犠牲にすることなく 音声のプライバシーにも配慮しています 私たちの新しいオンデバイス モデルはそのすべてを実現します 社内のパートナーと 緊密に連携し 開発者の皆さんに優れた体験を 提供できるように設計しました 現在は 皆さん自身のアプリで同じ ユースケースに対応できるようになりました SpeechTranscriberを使用することで 強力な音声テキスト変換モデルを 自分で調達 管理することなく 利用できます 関連するモデルアセットを 新しい AssetInventory APIを使用して インストールするだけです これらは必要に応じてダウンロードできます モデルはシステムストレージに 保持されるため アプリのダウンロードサイズや ストレージ使用量は増加しません ランタイムメモリサイズも 増加しません このモデルはアプリの メモリ空間の外部で動作するため サイズ制限を超えることを 心配する必要はありません モデルは継続的に改善されており 新しいアップデートが利用可能になると 自動的にインストールされます SpeechTranscriberは現在 これらの言語に 対応しており 今後さらに増える予定です watchOSを除き 特定のハードウェア要件を満たす すべてのプラットフォームで利用できます 対応していない 言語やデバイスが必要な場合は 代わりに DictationTranscriberクラスを利用できます このクラスがサポートする言語や 音声テキスト変換モデル デバイスは iOS 10のオンデバイス SFSpeechRecognizerと同じです ただし SFSpeechRecognizerの改善により ユーザーが設定アプリで 特定の言語用に Siriやキーボードの音声入力を オンにする必要がなくなりました 新しいAPIとモデルの概要の 紹介は以上です かなり抽象的でしたが ここからは具体的な説明です ではShantiniに交代して SpeechAnalyzerをアプリに 統合する方法を説明してもらいましょう 概要の説明をありがとう Donovan iOS 18でメモアプリに追加された すばらしい機能はご覧になったでしょうか 通話 ライブ音声 録音された音声を 記録し文字起こしすることができます さらに これらの機能を Apple Intelligenceと統合することで 実用的で時間を節約できる要約が 生成されるようになりました Speechチームと緊密に連携し SpeechAnalyzerと SpeechTranscriberを活用することで 高品質なメモアプリの機能を 提供できるようにしました SpeechTranscriberは 動作が高速で 遠くからでも 高精度な認識が可能であり オンデバイス モデルとして最適です 私たちの追加の目標の1つは 開発者である皆さんが メモアプリに追加されたような 機能を構築し ユーザーのニーズに合わせて カスタマイズできるようにすることです ぜひ そのお手伝いをさせてください ライブ文字起こし機能を備えた 作成中のアプリを見てみましょう 私のアプリは子ども向けで 寝る前の物語を録音して 文字起こしし 後でそれらを再生できるようにします これがリアルタイムでの 文字起こしの結果です
音声を再生すると 対応するテキストのセグメントが ハイライトされ 子どもたちはストーリーを 追うことができます それではプロジェクトの セットアップを見てみましょう
このサンプルアプリコードでは Recorderクラスと SpokenWordTranscriberクラスを 使用しています どちらもオブザーバブルにしています
また このStoryモデルを作成して 文字起こしの情報と表示に必要な その他の関連情報をカプセル化します 最後に 文字起こしビューと ライブ文字起こしビューと 再生ビュー そして録音ボタンと再生ボタンがあります このアプリは録音と再生の 状態も処理します まず 文字起こし機能をチェックしましょう ライブ文字起こしは 3つの簡単なステップで設定できます SpeechTranscriberを設定し モデルが存在することを確認して 結果を処理します SpeechTranscriberのセットアップでは ロケールオブジェクトと必要な オプションを使用して初期化します ロケールの言語コードは 文字起こしの対象となる言語に 対応しています Donovanが先ほど強調したように 暫定結果はリアルタイムの推測であり 確定結果は 最良の推測です ここでは 両方の結果が使用されています 暫定結果は 薄い色で表示され 結果が確定されると それに置き換えられます SpeechTranscriberで これを設定するために 次のようなオプションタイプを設定します タイミング情報を取得するために audioTimeRangeオプションを追加しています
これにより テキストの再生を 音声と同期できるようになります
さまざまなオプションを提供する 事前設定済みのプリセットもあります
これからセットアップするのは SpeechAnalyzerオブジェクトと SpeechTranscriberモジュールです
これにより 必要なオーディオ フォーマットを取得できるようになります
また 音声テキスト変換モデルが 配置されていることも確認できます
SpeechTranscriberの セットアップを完了するために AsyncStream入力への 参照を保存し アナライザーを起動します
SpeechTranscriber のセットアップが完了したので モデルを取得する方法を確認しましょう この「モデル確認」メソッドでは SpeechTranscriberが必要な言語での 文字起こしをサポートしていることを 確認する処理を追加します
また 言語がダウンロードされ インストールされていることも確認します
言語がサポートされているが ダウンロードされていない場合は AssetInventoryにリクエストを送信して サポートをダウンロードできます
文字起こしは完全に デバイス上で行われますが モデルは取得する必要があります ダウンロードリクエストには `progress`オブジェクトが含まれており これを使用して ユーザーに 処理の進行状況を知らせることができます
アプリの言語サポートで 一度に対応できる言語の数が 制限されている場合があります 上限を超えている場合は AssetInventoryにリクエストして 1つ以上の言語の割り当てを 解除し 空き枠を確保できます
モデルが取得できたので 楽しい部分 つまり結果に取りかかりましょう
SpeechTranscriberの セットアップコードの隣に タスクを作成し その参照を保存しています
暫定結果と確定結果のトラッキング用に 2つの変数も作成しています
SpeechTranscriberは AsyncStreamを介して結果を返します 各結果オブジェクトには 何種類かのフィールドがあります
最初に取得したいのは`text`です AttributedStringで表されています これは 音声セグメントの 文字起こしの結果です ストリームで結果を取得するたびに 暫定的な結果か 確定した結果かを 確認するために `isFinal`プロパティを使用します
暫定結果の場合は volatileTranscriptに保存します
確定結果を取得するたびに volatileTranscriptをクリアし その結果を finalizedTranscriptに追加します
暫定結果をクリアしないと 重複が発生する可能性があります
確定結果を取得するたびに 後で使用するために Storyモデルに書き込みます
また 条件付き書式を 設定するために SwiftUIのAttributedString APIを 使用しています
これにより 文字起こしの結果を 視覚化し 暫定から確定への移行を 示すことができます
文字起こしのタイミングデータを 取得する方法が気になりますか ありがたいことに これは 属性付き文字列に含まれています
各実行には`audioTimeRange` 属性が含まれており CMTimeRange形式で表されます ビューコードでこれを使用して 適切なセグメントをハイライトします 次に音声入力の設定方法を 確認しましょう
ユーザーが「Record」を 押したときに呼び出されるrecord関数内で 音声の許可をリクエストし AVAudioSessionを開始します また プロジェクトの設定で アプリがマイクを 使用できるように 設定されていることを確認します
次に 事前に作成済みの setUpTranscriber関数を 呼び出します
最後に 音声ストリームからの 入力を処理します そのセットアップの方法を確認しましょう ここでは複数の処理が発生します 非同期ストリームを返すように AVAudioEngineを設定し 受信バッファを ストリームに渡します
また 音声をディスクに書き込んでいます
最後に audioEngineを起動しています
Record関数に戻り AsyncStreamの入力を Transcriberに渡しています
音声ソースごとに出力フォーマットや サンプルレートが異なります SpeechTranscriberは使用可能な bestAvailableAudioFormatを 提供します
音声バッファを 変換ステップで処理し フォーマットがbestAvailableAudioFormat と一致するようにしています
次に 非同期ストリームを SpeechTranscriberのinputBuilder オブジェクトにルーティングします 録音を停止するときに いくつかの処理を行う必要があります 音声エンジンと Transcriberを停止しました タスクをキャンセルし アナライザーストリームで finalizeを呼び出すことが重要です これにより 暫定結果が 確定されます このすべてをビューに統合する 方法を確認しましょう
TranscriptViewは 現在のストーリーへのバインディングと SpokenWordTranscriberへの バインディングを備えています 録音中は 確定した文字起こしの 結果と暫定的な結果を連結して 表示します 暫定的な結果は SpokenWordTranscriberクラスから 取得されます 再生時は データモデルから取得した 最終的な文字起こしを表示します 文章を分割するメソッドも 追加しています 視覚的にすっきりと整理するためです
前にも述べた重要な機能の1つが 再生に 合わせて各単語をハイライトする機能です ここではいくつかの ヘルパーメソッドを使用して ハイライトの対象となる テキストを計算しています 基準となるのはaudioTimeRange属性と 現在の再生時間です
SpeechTranscriberの精度が高い 理由は数多くありますが 特に Apple Intelligenceを使用して 出力に高度な変換を 行える点も その1つです
ここでは 新しい FoundationModels APIを使用して ストーリーの完成時に タイトルを生成しています このAPIを使用すると簡単に すてきなタイトルを生成でき 頭を悩ませる必要はありません FoundationModels APIの 詳細については 「Meet the foundation models framework」 というセッションをご覧ください
このアプリを実際に試してみましょう +ボタンをタップして 新しいストーリーを作成します
次に録音を開始します 「昔々 神秘の国マゼンタに デリラという名前の少女が 丘の上の城に暮らしていました デリラは日々 森を探索し そこに住む 動物の世話をして過ごしていました
作業を完了した後 ユーザーが再生すると 各単語が 音声に合わせてハイライトされます
昔々 神秘の国マゼンタに デリラという名前の少女が 丘の上の城に暮らしていました
デリラは日々 森を探索し そこに住む動物の世話をして 過ごしていました SpeechAnalyzerとSpeechTranscriber により 極めて短い時間で アプリ全体を構築できました 詳細については Speechフレームワークの ドキュメントをご参照ください これには 作成した サンプルアプリも含まれています 以上がSpeechAnalyzerの解説です これを活用して すばらしいアプリを開発してください ご視聴ありがとうございました
-
-
5:21 - Transcribe a file
// Set up transcriber. Read results asynchronously, and concatenate them together. let transcriber = SpeechTranscriber(locale: locale, preset: .offlineTranscription) async let transcriptionFuture = try transcriber.results .reduce("") { str, result in str + result.text } let analyzer = SpeechAnalyzer(modules: [transcriber]) if let lastSample = try await analyzer.analyzeSequence(from: file) { try await analyzer.finalizeAndFinish(through: lastSample) } else { await analyzer.cancelAndFinishNow() } return try await transcriptionFuture
-
11:02 - Speech Transcriber setup (volatile results + timestamps)
func setUpTranscriber() async throws { transcriber = SpeechTranscriber(locale: Locale.current, transcriptionOptions: [], reportingOptions: [.volatileResults], attributeOptions: [.audioTimeRange]) }
-
11:47 - Speech Transcriber setup (volatile results, no timestamps)
// transcriber = SpeechTranscriber(locale: Locale.current, preset: .progressiveLiveTranscription)
-
11:54 - Set up SpeechAnalyzer
func setUpTranscriber() async throws { transcriber = SpeechTranscriber(locale: Locale.current, transcriptionOptions: [], reportingOptions: [.volatileResults], attributeOptions: [.audioTimeRange]) guard let transcriber else { throw TranscriptionError.failedToSetupRecognitionStream } analyzer = SpeechAnalyzer(modules: [transcriber]) }
-
12:00 - Get audio format
func setUpTranscriber() async throws { transcriber = SpeechTranscriber(locale: Locale.current, transcriptionOptions: [], reportingOptions: [.volatileResults], attributeOptions: [.audioTimeRange]) guard let transcriber else { throw TranscriptionError.failedToSetupRecognitionStream } analyzer = SpeechAnalyzer(modules: [transcriber]) self.analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriber]) }
-
12:06 - Ensure models
func setUpTranscriber() async throws { transcriber = SpeechTranscriber(locale: Locale.current, transcriptionOptions: [], reportingOptions: [.volatileResults], attributeOptions: [.audioTimeRange]) guard let transcriber else { throw TranscriptionError.failedToSetupRecognitionStream } analyzer = SpeechAnalyzer(modules: [transcriber]) self.analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriber]) do { try await ensureModel(transcriber: transcriber, locale: Locale.current) } catch let error as TranscriptionError { print(error) return } }
-
12:15 - Finish SpeechAnalyzer setup
func setUpTranscriber() async throws { transcriber = SpeechTranscriber(locale: Locale.current, transcriptionOptions: [], reportingOptions: [.volatileResults], attributeOptions: [.audioTimeRange]) guard let transcriber else { throw TranscriptionError.failedToSetupRecognitionStream } analyzer = SpeechAnalyzer(modules: [transcriber]) self.analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriber]) do { try await ensureModel(transcriber: transcriber, locale: Locale.current) } catch let error as TranscriptionError { print(error) return } (inputSequence, inputBuilder) = AsyncStream<AnalyzerInput>.makeStream() guard let inputSequence else { return } try await analyzer?.start(inputSequence: inputSequence) }
-
12:30 - Check for language support
public func ensureModel(transcriber: SpeechTranscriber, locale: Locale) async throws { guard await supported(locale: locale) else { throw TranscriptionError.localeNotSupported } } func supported(locale: Locale) async -> Bool { let supported = await SpeechTranscriber.supportedLocales return supported.map { $0.identifier(.bcp47) }.contains(locale.identifier(.bcp47)) } func installed(locale: Locale) async -> Bool { let installed = await Set(SpeechTranscriber.installedLocales) return installed.map { $0.identifier(.bcp47) }.contains(locale.identifier(.bcp47)) }
-
12:39 - Check for model installation
public func ensureModel(transcriber: SpeechTranscriber, locale: Locale) async throws { guard await supported(locale: locale) else { throw TranscriptionError.localeNotSupported } if await installed(locale: locale) { return } else { try await downloadIfNeeded(for: transcriber) } } func supported(locale: Locale) async -> Bool { let supported = await SpeechTranscriber.supportedLocales return supported.map { $0.identifier(.bcp47) }.contains(locale.identifier(.bcp47)) } func installed(locale: Locale) async -> Bool { let installed = await Set(SpeechTranscriber.installedLocales) return installed.map { $0.identifier(.bcp47) }.contains(locale.identifier(.bcp47)) }
-
12:52 - Download the model
func downloadIfNeeded(for module: SpeechTranscriber) async throws { if let downloader = try await AssetInventory.assetInstallationRequest(supporting: [module]) { self.downloadProgress = downloader.progress try await downloader.downloadAndInstall() } }
-
13:19 - Deallocate an asset
func deallocate() async { let allocated = await AssetInventory.allocatedLocales for locale in allocated { await AssetInventory.deallocate(locale: locale) } }
-
13:31 - Speech result handling
recognizerTask = Task { do { for try await case let result in transcriber.results { let text = result.text if result.isFinal { finalizedTranscript += text volatileTranscript = "" updateStoryWithNewText(withFinal: text) print(text.audioTimeRange) } else { volatileTranscript = text volatileTranscript.foregroundColor = .purple.opacity(0.4) } } } catch { print("speech recognition failed") } }
-
15:13 - Set up audio recording
func record() async throws { self.story.url.wrappedValue = url guard await isAuthorized() else { print("user denied mic permission") return } #if os(iOS) try setUpAudioSession() #endif try await transcriber.setUpTranscriber() for await input in try await audioStream() { try await self.transcriber.streamAudioToTranscriber(input) } }
-
15:37 - Set up audio recording via AVAudioEngine
#if os(iOS) func setUpAudioSession() throws { let audioSession = AVAudioSession.sharedInstance() try audioSession.setCategory(.playAndRecord, mode: .spokenAudio) try audioSession.setActive(true, options: .notifyOthersOnDeactivation) } #endif private func audioStream() async throws -> AsyncStream<AVAudioPCMBuffer> { try setupAudioEngine() audioEngine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: audioEngine.inputNode.outputFormat(forBus: 0)) { [weak self] (buffer, time) in guard let self else { return } writeBufferToDisk(buffer: buffer) self.outputContinuation?.yield(buffer) } audioEngine.prepare() try audioEngine.start() return AsyncStream(AVAudioPCMBuffer.self, bufferingPolicy: .unbounded) { continuation in outputContinuation = continuation } }
-
16:01 - Stream audio to SpeechAnalyzer and SpeechTranscriber
func streamAudioToTranscriber(_ buffer: AVAudioPCMBuffer) async throws { guard let inputBuilder, let analyzerFormat else { throw TranscriptionError.invalidAudioDataType } let converted = try self.converter.convertBuffer(buffer, to: analyzerFormat) let input = AnalyzerInput(buffer: converted) inputBuilder.yield(input) }
-
16:29 - Finalize the transcript stream
try await analyzer?.finalizeAndFinishThroughEndOfInput()
-