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?

Foundry Local for AndroidでXRデバイス5機種にオンデバイス音声アシスタントを載せてみた(Galaxy XR / Quest 3 / MiRZA / Even G2 / Rokid)

0
Posted at

Foundry LocalのAndroid版がついにPreviewで公開

Foundry Localは「ネットワーク遅延をなくしたい」、「閉域環境やオンプレでAIを使いたい」といった要件もあるのですが、生成AIで課題になりがちな機密データを外部送信できない問題に対する1つの手段として利用できる仕組みです。LM StudioやOllamaといったローカルLLMサービスと同種のものでMicrosoftから提供されています。Foundry Localは以前にも取り上げたことがあり、当時はWindows版のFoundry Localについて、基本的な使い方とNPUの活用を記事にしました。

そのFoundry LocalのAndroid版、Foundry Local for AndroidがGitHubで公開されています(2026-10時点でv0.1.6 Preview)。

スマホでもローカルLLMが使える1つの方法で活用できそうです。また、Androidで動くなら、AndroidベースのXRデバイスでも動くはず。そこで手元にあるXRデバイス5機種で、音声の文字起こしと簡単な会話を、クラウドを使わずに端末の上だけで動かせるかを検証しました。
先に結論だけ書いてしまうと以下のようになります。興味深かったのは、Rokid AIグラス単体でもFoundry Localを使ってローカルLLMが動いたことです。スマホすら接続せずにローカルLLMでAIグラスが使える可能性があるのはアツい。

5機種すべてで動作しました。

デバイス 推論する場所 確認したモード 補足
Galaxy XR ヘッドセット本体 Embedded / IPC
Meta Quest 3 ヘッドセット本体 Embedded Google PlayがないためIPCは使えない。GMSなしでも動く
MiRZA スマホ(AQUOS R9) Embedded IPCは未検証
Even G2 スマホ(ROG Phone 9 Pro) Embedded / IPC Androidのスマホのみ
Rokid Glasses スマホ(ROG Phone 9 Pro)。非公式な方法でグラス本体でも スマホ:Embedded / IPC
グラス本体:Embedded
グラス本体はSDKのサポート外

モードは、Foundry Local for Androidの2つの使い方です。Embeddedは推論エンジンをアプリに同梱する方法、IPCはGoogle Playの「Foundry Local App」をインストールして、アプリから呼び出す方法です(詳しくは後述)。

この記事では、まずFoundry Localの仕組みとMicrosoft Foundryとの関係を整理し、そのうえでAndroid版のSDKを紹介します。次にSDKの最低限の使い方を示し、そのあと5機種での構成・実測・ハマり所を紹介します。

Foundry Local for AndroidはPreviewです。APIや挙動はリリースごとに変わる可能性があります。この記事の内容はv0.1.6時点のものです。

Foundry Localとは

Foundry Localは、生成AIモデルをユーザーの端末の上で動かすためのランタイムとSDKです。アプリに組み込んで使い、推論は端末の中だけで行います。

仕組み

Microsoft Learnのアーキテクチャの説明に、全体の構成図があります。

Foundry Localのアーキテクチャ
出典:Foundry Local architecture overview(Microsoft Learn)

構成要素 役割
Foundry Local Core API アプリのプロセスの中に読み込むネイティブライブラリ。モデルのダウンロード・読み込み・推論・解放をまとめて扱う。CLIや常駐サービスは不要
ONNX Runtime モデルを実行する推論エンジン。GPUやNPU向けの実行プロバイダーを読み込み、使えない場合はCPUで動く
Foundry Catalog クラウドにあるモデルの登録先。ハードウェアごとに最適化したONNXモデルを配布し、最初に使うときにダウンロードして端末にキャッシュする
WinML Windowsだけで使う。OSやWindows Updateから、ハードウェアに合った実行プロバイダーを取得する
REST API 任意。OpenAI互換のHTTPエンドポイントを、アプリのプロセスの中で立てられる

アプリは各言語のSDK(C# / JavaScript / Python / Rust)からCore APIを直接呼び出します。ランタイムごとアプリに同梱するので、ユーザーは別のインストーラーやサービスを入れる必要がありません。

Microsoft Foundryとの関係

Foundry Localは、Microsoftの生成AIの基盤であるMicrosoft Foundryの製品群の1つです。

製品 何をするか Azureサブスクリプション
Microsoft Foundry クラウドでエージェント・モデル・ツールを統合して管理する基盤 必要
Foundry Local アプリに組み込み、ユーザーの端末上で推論するランタイムとSDK 不要
Foundry Local on Azure Local 自社のKubernetesクラスタで、複数ユーザー向けに推論する 必要(Azure Arcで管理)
Foundry Local for Android Foundry LocalのAndroid向けSDK。GitHubで別リポジトリとして公開 不要

Foundry LocalとMicrosoft Foundryの接点は、構成図の中のFoundry Catalogです。

  • モデルは最初に使うときにカタログからダウンロードされ、以降は端末のキャッシュから読み込む
  • ダウンロードが済めば、推論は完全にオフラインで動く
  • プロンプトや推論結果がクラウドに送られることはない

つまり、モデルの調達はMicrosoft Foundryのカタログに頼り、推論そのものはAzureと切り離されて端末だけで完結する関係です。AzureのアカウントもAPIキーも要りません。

「Foundry Local = ONNX」は、2026年10月時点の端末向けFoundry Localの話です。最近ではONNX以外の形式も対応が進みつつあるようです。

  • Windows版Foundry Localが実行プロバイダーの登録に使っているWindows MLに、llama.cppによるGGUFモデルの実行が実験的に追加されました(2026-10-07の発表)。ONNXとGGUFを同じAPIで扱えます
  • サーバー向けのFoundry Local on Azure Localでは、カタログにONNX版と並んでvLLM版(Hugging Face形式)のモデルがあります(推論ランタイムの説明)

端末向けのFoundry Local(Windows版・Android版)がONNX以外に対応するという発表は、今のところないようです。なお、Android版v0.1.6のカタログは、今はすべてONNXのCPU用モデルだけのようです。

Foundry Local for Android

Foundry LocalをAndroidアプリの中で使うためのSDKです。READMEの冒頭には「no cloud, no cost per request, fully private」とあります。

2026-10時点のMicrosoft Learnでは、Foundry Localの対応プラットフォームはWindows / macOS / Linuxとされています。Android版はLearnにはまだ載っておらず、GitHubのリポジトリが一次情報です。

構成図との対応

先ほどの構成図に当てはめると、Android版は次のようになります。

構成図の要素 Foundry Local for Android
アプリのコード(C# / JavaScript / Python / Rust) Kotlin
Foundry Local Core API カタログ・ダウンロード・読み込み・推論・解放をまとめて扱うマネージャーのAPI(リリースノートの「unified manager API」)
Foundry Catalog 同じカタログを使う。通信を見ると、カタログはai.azure.comから、モデル本体は*.api.azureml.msから取得していた
WinML なし(Windows専用)。v0.1.6のカタログにあるのはCPU向けのモデルだけ
REST API なし。代わりに、別アプリ(Foundry Local App)に推論を任せるIPCモードがある

2つのデプロイモード

SDKは2種類のAARで配布されています。どちらもKotlinのAPIは同じで、アプリにはどちらか一方だけを入れます。

モード AAR 推論する場所 特徴 向いている場面
Embedded foundry-local-embedded-sdk-0.1.6.aar アプリのプロセス内 推論エンジンを丸ごと同梱。他のアプリに依存しない Google Playがない端末(Meta Quest 3など)。アプリ単体で配布したい、利用者に別アプリを入れてもらいたくない場合
IPC foundry-local-ipc-sdk-0.1.6.aar 別アプリ「Foundry Local App」 APKは小さいが、Google PlayからFoundry Local Appを入れておく必要がある 複数のアプリでローカルLLMを使う場合。モデルはFoundry Local Appにまとめて置かれる(同時に使うときの注意は後述)

Microsoftのリリースノートも、「ほとんどのアプリはIPCでAPKを小さく保ち、アプリ単体で完結させる必要があるときはEmbeddedを」という方針です。

foundry-local-app.jpg
Google Play版のFoundry Local App。「This application runs as a background service.」とあるとおり、画面は案内だけで、本体は他のアプリから呼ばれる裏方のサービスです

XRデバイスでどちらが使えるかは、Google Playの有無で決まります。Galaxy XRにはGoogle Playがあるので、両方のモードを使えます。Meta Quest 3にはGoogle Playがなく、Foundry Local Appを入れられないため、Embeddedしか使えません。今回のサンプルは、ビルドの設定で両方を切り替えられるようにしました(後述)。

動作要件

リリースノートに書かれている要件は次のとおりです。

項目 内容
最小APIレベル 33(Android 13)
ターゲットAPIレベル 36
Foundry Local App IPCモードのときだけ必要。Google Playで配布されている
API IPCとEmbeddedで、同じコルーチンベースのAPIを使える

カタログのモデル

実際にカタログを取得すると47モデルが並び、すべてCPU向けでした。Windows版のようにNPUやGPU向けのモデルは、v0.1.6のカタログにはありません。

用途 カタログにあったモデル(抜粋)
文字起こし Whisper(tiny / base / small / medium / large-v3-turbo)、Nemotron streaming(英語・スペイン語)、parakeet
チャット Qwen2.5 / Qwen3 / Qwen3.5、Phi-4-mini、gemma-4など

SDKとして利用できる機能

  • チャット(ストリーミング、マルチターン)
  • 文字起こし(音声ファイル、ストリーミング)
  • モデルのダウンロード・読み込み・解放・キャッシュ管理(ダウンロードの進捗付き)

開発環境

項目 バージョン
Foundry Local for Android v0.1.6(Embedded / IPCのAAR)
Android Gradle Plugin / Kotlin Kotlin DSL、JDK 17
compileSdk / minSdk 37 / 33
kotlinx-coroutines-android 1.10.2
Jetpack Compose BOM(パネルアプリ) 2026.08.00
Unity(MiRZA) 6000.0.67f1 + Snapdragon Spaces 1.0.3 + MiRZA Library 1.2.1

検証した端末は次のとおりです。

デバイス 推論した端末 SoC / RAM OS
Galaxy XR 本体(SM-I610) Snapdragon XR2+ Gen 2 / 16GB Android XR(Android 14ベース)
Meta Quest 3 本体 Snapdragon XR2 Gen 2 / 8GB Horizon OS(Android 14ベース、GMSなし)
MiRZA AQUOS R9(SH-51E) Snapdragon 7+ Gen 3 / 12GB Android 16
Even G2 ROG Phone 9 Pro Snapdragon 8 Elite / 24GB Android 16
Rokid Glasses ROG Phone 9 Pro 同上 同上

Getting Started:SDKの最低限の使い方

ここではEmbeddedモードで、初期化からチャットと文字起こしまでを通します。コードは今回作ったサンプルの共通コアから抜粋しています。

1. AARを組み込む

GitHub ReleasesからAARをダウンロードし、libs/に置きます。

app/build.gradle.kts
android {
    defaultConfig {
        minSdk = 33 // SDK の最小 API レベル
    }
}

dependencies {
    implementation(files("libs/foundry-local-embedded-sdk-0.1.6.aar"))
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
}

マニフェストには、カタログとモデルの取得用にINTERNETを宣言します。マイクを使うならRECORD_AUDIOも必要です。

AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />

2. 初期化してモデルを準備する

FoundryLocalManagerを作り、カタログから取ったモデルを「ダウンロード → 読み込み」の順に準備します。

FoundryEngine.kt
import com.microsoft.foundrylocal.api.Configuration
import com.microsoft.foundrylocal.api.FoundryLocalManager
import com.microsoft.foundrylocal.api.Model

suspend fun createManager(context: Context): FoundryLocalManager {
    return FoundryLocalManager.create(
        context.applicationContext,
        Configuration(appName = "FoundryLocalXR")
    )
}

/** モデルを取得し、必要ならダウンロードと読み込みを行う */
suspend fun prepare(manager: FoundryLocalManager, modelId: String, onProgress: (Float) -> Unit): Model {
    val model = manager.getCatalog().getModel(modelId)
    if (!model.isCached()) model.download(progress = { onProgress(it) })
    if (!model.isLoaded()) model.load()
    return model
}

カタログの中身はlistModels()で取れます。トラブルシューティングのドキュメントには、モデルのエイリアスはリリースごとに変わり得るのでハードコードしないようにとあります。そこでサンプルでは、候補の名前を前方一致で探し、見つかったものの中から一番小さいモデルを選ぶようにしました。

FoundryEngine.kt
/** 候補(モデル ID の前方一致)を順に探し、見つかったうち最小のものを返す */
suspend fun resolve(manager: FoundryLocalManager, preferences: List<String>): String {
    val models = manager.getCatalog().listModels()
    for (pref in preferences) {
        models.filter { modelId(it).startsWith(pref) }
            .minByOrNull { it.fileSizeMb }
            ?.let { return modelId(it) }
    }
    error("No catalog model matches $preferences")
}

fun modelId(info: ModelInfo): String =
    if (info.name.contains(':')) info.name else "${info.name}:${info.version}"

// 例: resolve(manager, listOf("openai-whisper-base", "openai-whisper-small"))
//     → "openai-whisper-base-generic-cpu:3"

3. チャット

createChatClient()で得たクライアントに、completeChatStreamingで問い合わせます。SDKは会話履歴を持たないので、マルチターンにしたい場合は履歴を毎回送ります。

ChatSession.kt
class ChatSession(
    private val client: ChatClient,
    private val systemPrompt: String,
    private val maxTurns: Int = 4,
    private val maxTokens: Int = 200
) {
    private val history = ArrayDeque<ChatMessage>()

    /** 返答を少しずつ流す。流し終えたら返答全体を履歴に追加する */
    fun reply(userText: String): Flow<String> = flow {
        history.addLast(ChatMessage.user(userText))
        while (history.size > maxTurns * 2 - 1) history.removeFirst()
        val request = ChatCompletionRequest(
            messages = listOf(ChatMessage.system(systemPrompt)) + history,
            maxTokens = maxTokens
        )
        val answer = StringBuilder()
        client.completeChatStreaming(request).collect { chunk ->
            if (chunk.delta.isNotEmpty()) {
                answer.append(chunk.delta)
                emit(chunk.delta)
            }
        }
        history.addLast(ChatMessage.assistant(answer.toString()))
    }
}

4. 文字起こし(ファイル)

Whisperは音声ファイルのパスを渡して文字起こしします。入力は16kHz・モノラル・16bitのWAVです。

val audio = whisperModel.createAudioClient()
val text = audio.transcribe(
    AudioTranscriptionRequest(filePath = wav.absolutePath, language = "ja")
).text

5. 文字起こし(ストリーミング)

createStreamSessionでPCMを少しずつ流し込むと、話している途中の仮の結果が返ってきます。

val session = audio.createStreamSession(
    AudioStreamSettings(sampleRate = 16_000, channels = 1, bitsPerSample = 16, language = "en")
)
pcmFlow.collect { chunk ->
    val result = session.pushAudioChunk(chunk)
    if (result.text.isNotEmpty()) showPartial(result.text) // セッション全体の現時点の結果
}
val last = session.stop()

ストリーミングに対応しているのはNemotronで、英語とスペイン語だけです。Whisperはファイル単位の文字起こし用で、公式のサンプルも同じ使い方をしています。日本語をライブで文字起こししたい場合は、次の章のように自分で組み立てる必要があります。

日本語をライブで文字起こしする

XRデバイスで話しかけて使うには、日本語のライブ文字起こしが欲しいところです。そこで「マイクの音を発話ごとに切り出して、1発話ずつWhisperに渡す」方式を実装しました。

処理の流れは次のとおりです。

  1. マイクから16kHzのPCMを受け取る
  2. VADで発話を切り出す
  3. 1発話分のPCMをキューに入れる
  4. WhisperがWAVにして文字起こしする
  5. 確定した文字をキューに入れる
  6. チャットモデル(Qwen2.5 1.5B)が返事を作る
  7. パネルやグラスに表示する

切り出しと推論を別々のコルーチンで回す

最初は、VAD(発話の区間を検出する処理)とWhisperを同じcollectの中で順に呼んでいました。するとWhisperが数秒かけて推論している間はマイクが読まれず、遅れがどんどん積み上がります。Galaxy XRでは、3発話目で10秒の遅れが出ました。

channelFlowを使って、切り出しと推論を別のコルーチンに分けて解決しました。チャットも同じ理由で、Channelを挟んだ別のコルーチンで処理しています。

Transcribers.kt
override fun transcribe(pcm: Flow<ByteArray>): Flow<TranscriptEvent> = channelFlow {
    // Whisper の推論中もマイクを読み続けるため、切り出しは別コルーチンで回す
    val utterances = Channel<ByteArray>(Channel.UNLIMITED)
    launch {
        try {
            cutUtterances(pcm) { utterances.send(it) }
        } finally {
            utterances.close()
        }
    }
    var index = 0
    for (bytes in utterances) {
        send(TranscriptEvent.Partial("…"))
        val text = transcribePcm(bytes, "utt_${index++ % 4}.wav")
        if (text.isNotBlank() && !isHallucination(text)) send(TranscriptEvent.Final(text))
        else send(TranscriptEvent.Partial(""))
    }
}

cutUtterancesは、VADが発話の始まりを検出したら、その300ms前からPCMをためます。終わりを検出したら、ためた分を1発話として渡します。こうすると、話し始めの一音が欠けません。

Whisperの「幻聴」を捨てる

Whisperは無音や雑音に対しても何かしら文字を返します。代表的な例は「(拍手)」です。Rokidでは、手を叩いた音が「(パンッ)」と書き起こされました。こうした出力は発話として扱わないようにしています。

Transcribers.kt
private fun isHallucination(text: String): Boolean {
    val t = text.trim().trim('.', '。', '!', ' ').lowercase()
    return t.isEmpty() || t in setOf(
        "thank you", "thanks for watching", "you", "[music]", "(music)", "[blank_audio]",
        "ご視聴ありがとうございました", "ありがとうございました"
    ) || NON_SPEECH.matches(t)
}

/** 「[Music]」「(拍手)」「(パンッ)」「(笑)」のような、かっこだけの出力 */
val NON_SPEECH = Regex("""^[\[((【].*[\]))】]$""")

VADの調整

VADは簡易的に音量(RMS)で判定するものを作りました。周囲の雑音レベル(ノイズフロア)を追いかけて、しきい値を自動で上げ下げします。ただ、マイクの音量はデバイスによって様々で、少し調整が必要です。

デバイス 無音時のRMS 普通の声のRMS 最低しきい値
Galaxy XR 15〜30 200〜300 120
Rokid(Bluetooth SCO) 0〜1 100〜300(瞬間的) 40
Even G2(BLE) 約170 229〜809 120(適応で520〜690まで上昇)

例えば、最低しきい値を350にしていた最初の版では、Galaxy XRで「最初の1回しか反応しない」ように見えました。1回目は大きな声で話していたので拾えたものの、2回目以降の普通の声がしきい値に届いていなかったためです。この他にも、Even G2では「VADが発話中のまま戻らなくなる」場合がありました。マイクが一時的に止まって無音が続くと、しきい値が背景雑音より低くなります。これらの対応した、最終的なVADは次のようにしています。

Audio.kt
fun feed(chunk: ByteArray): Event {
    val ms = chunk.size * 1000 / Pcm.BYTES_PER_SECOND
    val level = rms(chunk)
    val threshold = maxOf(minThreshold, noiseFloor * floorRatio)
    trackNoise(level)
    if (!inSpeech) {
        speechMs = if (level > threshold) speechMs + ms else 0
        if (speechMs >= minSpeechMs) { inSpeech = true; /* ... */ return Event.SPEECH_START }
        return Event.NONE
    }
    utteranceMs += ms
    levelSum += level; levelCount++
    // ヒステリシス:話している間は、しきい値の半分を下回ったときだけ無音とみなす(語尾を切らない)
    silenceMs = if (level > threshold * 0.5) 0 else silenceMs + ms
    if (silenceMs >= endSilenceMs || utteranceMs >= maxUtteranceMs) {
        if (silenceMs < endSilenceMs && levelCount > 0) {
            // 長さの上限で切れた = 背景雑音を発話と誤認していた。ノイズフロアをその平均まで上げる
            noiseFloor = maxOf(noiseFloor, levelSum / levelCount)
        }
        inSpeech = false
        speechMs = 0
        return Event.UTTERANCE_END
    }
    return Event.NONE
}

/** ノイズフロアは、下がるときは速く、上がるときはゆっくり(約 20 秒)追う。デジタル無音は無視する */
private fun trackNoise(level: Double) {
    if (level < 1.0) return
    noiseFloor = if (level < noiseFloor) noiseFloor * 0.9 + level * 0.1
                 else noiseFloor * 0.995 + level * 0.005
}

5機種の構成

次に実際に各デバイスでの実装や動作を共有したいと思います。今回は、共通(文字起こし・VAD・チャット)は1つにまとめ、デバイスごとに合わせて「音声をどこから取るか」と「結果をどこに出すか」を個別に実装しました。

サンプルのコードはGitHubで公開しています。

https://github.com/TakahiroMiyaura/FoundryLocalXR

Foundry Local for AndroidのAARはリポジトリに含めていません。libs/fetch-sdk.ps1でGitHub Releasesから取得してからビルドしてください。

デバイス 推論する場所 アプリの形 マイク 表示先
Galaxy XR / Quest 3 ヘッドセット本体 Jetpack Composeの2Dアプリ。両方に同じAPKを入れる 本体のマイク 空間に浮かぶ2Dウィンドウ
MiRZA スマホ スマホのUnityアプリ。Kotlinの共通コアをブリッジ経由で呼ぶ グラスのマイク(USB) スマホ画面とグラス
Even G2 スマホ Even HubのWebアプリ(Even AppのWebView)と、同じスマホのcompanionアプリ。両者はWebSocketでつなぐ グラスのマイク(BLE) グラス
Rokid スマホ スマホアプリと、グラス側の表示用アプリ。表示する文字はWi-FiのUDPで送る グラスのマイク(Bluetooth SCO) グラス

Galaxy XRはEmbeddedとIPCの両方、Google PlayのないQuest 3はEmbeddedだけで動かしました。スマホで推論する構成も、スマホにGoogle PlayがあるのでIPCモードを使えます。Even G2とRokidのスマホアプリは、Galaxy XRと同じくproduct flavorで両方をビルドできるようにし、IPC版でもグラスのマイクでの会話まで確認しました。2つのアプリを順番に起動すると、後から起動したほうは先に取得されたモデルをダウンロードなしで読み込みました。MiRZA(Unity)はEmbeddedだけで試しています。

実測

モデルはチャットにqwen2.5-1.5b-instruct、文字起こしにWhisper(baseかsmall)を使いました。時間は「話し終わってから文字が出るまで」と「文字が出てから返事が出るまで」です。

デバイス Whisper 1発話の文字起こし 返事
Galaxy XR base 約1.4秒(1.3〜2.0秒) 1〜6秒
Meta Quest 3 base 1.6〜1.8秒 1.6〜3.4秒
MiRZA(AQUOS R9) base 0.8〜1.1秒 0.5〜0.8秒
Even G2(ROG Phone 9 Pro) small 約2秒 0.5〜1.5秒
Rokid(ROG Phone 9 Pro) small 1.6〜1.9秒 0.6〜0.8秒
Rokidグラス単体(参考) tiny 約7秒(約8秒の音声) 約5秒(qwen2.5-0.5b)

ヘッドセットのCPUではWhisper smallは重く、Galaxy XRで1発話3.7〜5.2秒かかりました。そのため、ヘッドセットではbaseを使っています。tinyは0.8秒と速いものの、「XR」が「エクサール」になるなど精度が落ちました。スマホで推論する構成ならsmallでも2秒前後です。

返事の中身は、1.5Bのモデルなりです。日本語で返ってはきますが、内容の質は期待しすぎないほうがよいです。

デバイス別の実装

Galaxy XR / Meta Quest 3:同じAPKを2Dパネルとして動かす

Galaxy XRとQuest 3には、Jetpack Composeで作った普通のAndroidアプリを構築しました。あくまでFoundry Localの動作確認が目的になります。Quest 3もJetpack Composeが使えるので、同じAPKがそのまま両方で動きます。Unityで扱う方法はMiRZAの検証時に実現しています。

Android XRエミュレータで動かしたapp-panel
Android XRエミュレータ(x86_64)で動かしたapp-panel。エミュレータでも動くので、実機の前に検証できます

Questの公式ドキュメント(Horizon OS apps)にも、Jetpack Composeなどで作った2Dアプリが動くと書かれています。今回のAPKにはMetaのSDKを入れておらず、マニフェストで推奨されているuses-metavr-sdkの宣言も入れていません。それでもサイドロードで動きましたが、配布するなら宣言を入れておくのがよいでしょう。

EmbeddedとIPCをビルドで切り替える

2つのAARはKotlinのAPIが同じなので、アプリのコードは1つのまま、Gradleのproduct flavorでAARだけを差し替えられます。

app-panel/build.gradle.kts
android {
    flavorDimensions += "runtime"
    productFlavors {
        // 推論エンジンを APK に同梱(Quest 3 はこちらのみ)
        create("embedded") { dimension = "runtime" }
        // Google Play の Foundry Local App を使う(Galaxy XR / スマホ)
        create("ipc") {
            dimension = "runtime"
            applicationIdSuffix = ".ipc" // 両方を同じ端末に入れて比べられるように
        }
    }
}

dependencies {
    implementation(project(":core"))
    "embeddedImplementation"(files(rootProject.file("libs/foundry-local-embedded-sdk-0.1.6.aar")))
    "ipcImplementation"(files(rootProject.file("libs/foundry-local-ipc-sdk-0.1.6.aar")))
    // files() で参照した AAR は依存関係が解決されないので、IPC 版は AndroidX Core を自分で追加する
    "ipcImplementation"("androidx.core:core-ktx:1.17.0")
}

パッケージ名を分けても、表示名が同じだとホーム画面で見分けがつきません。src/ipc/res/values/strings.xmlにapp_nameを置き、IPC版の名前に「(IPC)」を付けています。

共通コア(:core)はEmbeddedのAARをcompileOnlyで参照し、APIの型だけを借りています。実体はアプリ側のflavorが入れる、という分担です。

./gradlew :app-panel:assembleEmbeddedDebug   # Galaxy XR / Quest 3
./gradlew :app-panel:assembleIpcDebug        # Galaxy XR(Foundry Local App を先に入れておく)

Galaxy XRで、同じ音声ファイル(約8秒)の文字起こしと、「こんにちは」への返事を両方のモードで比べました。

Embedded IPC
APKサイズ(debug、arm64-v8aのみ) 約88MB 約30MB
文字起こし(Whisper base) 1.5〜1.7秒 1.5〜1.7秒
返事(qwen2.5-1.5b) 約1.1秒 約1.1秒
カタログ 全モデルCPU用 全モデルCPU用(2026-10-19時点で44件。カタログは随時更新される)

速度は変わらず、カタログに並ぶのもCPU用のモデルだけでした。IPCは推論を別のアプリ(Foundry Local App)に任せるだけで、速さや使える処理装置はEmbeddedと変わらないようです。違いはAPKのサイズと、モデルの保存先がアプリ側かFoundry Local App側か、の2点です。

Galaxy XR

  • ヘッドセットを装着して話しかけ、6発話すべてを取りこぼしなく文字起こしできました
  • 英語のNemotronは、音声の長さの0.45倍の時間で処理でき、ストリーミングとして成り立ちました
  • 初回は必ずネットにつなぐ必要があります。Wi-Fi未接続だと、カタログを取りに行くai.azure.comの名前解決に失敗して初期化が落ちます

Meta Quest 3

  • QuestにはGoogle PlayもGMSもありませんが、Embeddedモードは問題なく初期化・カタログ取得・推論まで動きました
  • 装着していないとQuestがスリープし、ダウンロードが止まります。開発中はadbで近接センサーを無効にしておくと楽です
adb shell am broadcast -a com.oculus.vrpowermanager.prox_close

Galaxy XRで話しかけて返事が出るまで
Galaxy XRで話しかけて返事が出るまで

Meta Quest 3で話しかけて返事が出るまで
Meta Quest 3で話しかけて返事が出るまで

MiRZA:UnityからKotlinのSDKを呼ぶ

MiRZAは、AQUOS R9などのスマホにUSBでつないで使うグラスです。アプリはスマホ側で動き、Unity(Snapdragon SpacesとMiRZA Library)で作ります。今回はKotlinの共通コアをAARにし、Unityから呼ぶためのブリッジ(unity-bridge)を挟みました。

Kotlin側は、UnityからCallStaticで呼べる静的メソッドだけを公開しています。結果はUnitySendMessageでJSONにして返します。

FoundryLocalBridge.kt(抜粋)
object FoundryLocalBridge {
    @JvmStatic
    @Synchronized
    fun init(activity: Activity, gameObjectName: String, configJson: String?): Boolean {
        val m = UnityMessenger(gameObjectName)
        val config = BridgeJson.parseConfig(configJson)
        val s = CoroutineScope(SupervisorJob() + Dispatchers.Default)
        val a = VoiceAssistant(activity.applicationContext, s, config)
        a.onFinalTranscript = { m.send("OnBridgeTranscript", BridgeJson.text(it)) }
        a.onReplyDelta = { m.send("OnBridgeReplyDelta", BridgeJson.text(it)) }
        a.onReply = { m.send("OnBridgeReply", BridgeJson.text(it)) }
        s.launch {
            a.state.map(BridgeJson::view).distinctUntilChanged().collect {
                m.send("OnBridgeState", BridgeJson.state(it))
            }
        }
        a.prepare() // モデルのダウンロードと読み込み(進捗は OnBridgeState で通知)
        return true
    }

    /** MiRZA のグラスのマイクで聞き取りを始める */
    @JvmStatic
    fun startGlassMic(): Boolean = listen("startGlassMic") { GlassMic.pcm(appContext!!) }
}

MiRZAのグラスのマイクは、スマホからはTYPE_IP(「in-proxy device」)という入力デバイスに見えます。これをsetPreferredDeviceで指定して録音します。

GlassMic.kt(抜粋)
fun findGlassMic(context: Context): AudioDeviceInfo? {
    val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
    return am.getDevices(AudioManager.GET_DEVICES_INPUTS)
        .firstOrNull { it.type == AudioDeviceInfo.TYPE_IP }
}

MiRZAアプリのスマホ側の画面
AQUOS R9側の画面。グラスには返事だけを表示しています

MiRZAで使ったunity-bridgeはMiRZA専用ではありません(グラスのマイクの部分を除く)。Galaxy XRやQuest 3のUnityアプリから使う場合にも応用できる見込みです(未検証)。

MiRZAのグラス表示(話しかけて返事が出るまで)
MiRZAのグラス表示(話しかけて返事が出るまで)

Even G2:WebViewから同じスマホのcompanionアプリへ

Even G2のアプリは、スマホのEven Appの中のWebViewで動くWebアプリ(Even Hub)だけです。ネイティブのSDKを直接は呼べません。そこで、同じスマホにFoundry Localを載せたcompanionアプリを置き、WebViewからws://127.0.0.1:18765でつなぐ構成にしました。companionはKtorのWebSocketサーバーで、Foreground Serviceとして動かしています。

間にアプリを挟むのは、Android版のFoundry LocalにWebから届く入口がないためです。Windows版にあるローカルのRESTサーバーはAndroid版にはありません。IPCモードのFoundry Local Appも、Androidアプリ同士が直接呼び合う仕組み(AIDL)でしかつながりません。この違いが気になって当時Issue #9でREST APIの予定を尋ねたところ、回答は「AIDLのbound serviceのほうが適している」でした。WebViewのJavaScriptから使うには、自前で中継するしかありません。

一番のリスクは、Even AppがWebViewからループバックアドレスへの通信を許すかどうかでした。app.jsonのnetwork権限のwhitelistにループバックのURLを書くと、パッケージ版(private build)でも通信できました。

app.json(抜粋)
{
  "permissions": [
    { "name": "g2-microphone", "desc": "..." },
    {
      "name": "network",
      "desc": "Talks to the companion app on this phone over loopback only.",
      "whitelist": [
        "http://127.0.0.1:18765",
        "ws://127.0.0.1:18765",
        "http://127.0.0.1",
        "ws://127.0.0.1"
      ]
    }
  ]
}
  • 開発中は、例えばadb reverse tcp:5173 tcp:5173でPCの開発サーバーをスマホに見せ、http://127.0.0.1:5173のQRコードをEven Appで読ませると、スマホとPCが別のネットワークにいても試せます
  • G2の画面は小さいので、文字数を絞る意味でmaxTokens = 80を指定し、物理的に情報を切りました
  • G2のマイクはBLE経由で背景雑音が大きめです(無音でもRMS約170)
  • Even Appとcompanionが同じスマホにある必要があるため、Android限定です(iOSでは不可)

Even G2のグラス表示(話しかけて返事が出るまで)
Even G2のグラス表示(話しかけて返事が出るまで)

Rokid:スマホで推論する構成と、グラス単体の実験

Rokid Glasses(RV101)は、グラス自体がAndroidベースのOS(YodaOS-Sprite)で動いています。ただし、そのまま入れられない理由が2つあります。

  • OSがAndroid 12(API 32)で、SDKのminSdk 33を満たさない
  • RAMが2GBしかない

そこで本命の構成は、スマホで推論してグラスには表示だけさせる形にしました。グラス単体での実験は、この節の後半で紹介します。

  • マイク:グラスはスマホからBluetoothヘッドセット(HFP)として見えるので、Bluetooth SCOで録音しました。Android標準のAudioManager.setCommunicationDeviceでSCOの入力に切り替えられるので、RokidのCXR SDKは使わず、直接AudioManagerを使っています
  • 表示:グラス側に小さな表示用アプリを入れ、スマホからWi-FiのUDPでテキストを送りました
GlassesMic.kt(抜粋)
val am = context.getSystemService(AudioManager::class.java)
val sco = am.availableCommunicationDevices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
    ?: error("グラスが Bluetooth ヘッドセットとして接続されていません")
am.mode = AudioManager.MODE_IN_COMMUNICATION
check(am.setCommunicationDevice(sco))

val recorder = AudioRecord(
    MediaRecorder.AudioSource.VOICE_COMMUNICATION,
    16_000, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSize
)
// 録音は SCO の入力側デバイスに固定する
am.getDevices(AudioManager.GET_DEVICES_INPUTS)
    .firstOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
    ?.let { recorder.setPreferredDevice(it) }

Rokid用スマホアプリの画面
ROG Phone 9 Pro側の画面。「グラスに送っている内容」がグラスに表示されます

  • SCOで届く音は強いノイズ抑制がかかっていて、とても小さいです。VADの最低しきい値を40まで下げました
  • SCOは電話向けの音質のため、聞き間違いは他のデバイスより多めでした
  • ROG Phoneでは、SCOの出力デバイスのproductNameにグラスではなくスマホ自身の型番が入っていました。グラスの名前は入力側のデバイスから取る必要があります

Rokid(スマホで推論)のグラス表示
Rokid(スマホで推論)のグラス表示

実験:グラス単体で動かしてみる

ここまでの構成で、スマホで推論すればRokidでも十分に使えることは分かりました。ただ、グラス自体もAndroidベースのOSで動いています。足りないのはAPIレベルが1つだけで、ネイティブのライブラリもarm64です。もしかしたら動くのでは、と思い、minSdkのチェックを外してAPI 32のグラスに直接入れて試してみました。

結果は、グラス単体で文字起こしも会話も動きました。

Rokidグラス単体で動かした画面
グラスの画面の録画。マイクで「こんにちは」と話しかけて返事が出ている

SDKがサポートしていない使い方です。AARが宣言するminSdk 33を、マニフェストのtools:overrideLibraryで無理やり通しています。今回の版でたまたま動いただけで、将来の版で動く保証はありません。この方法で起きた問題は、Microsoftのサポートの対象外です。製品やサービスには使わず、あくまで「こういう可能性もある」という実験として読んでください。

AndroidManifest.xml(グラス用ビルドのみ)
<uses-sdk tools:overrideLibrary="com.microsoft.foundrylocal.embedded,com.example.flxr.core" />
build.gradle.kts(-PminSdkOverride=32 のときだけ minSdk を下げる)
providers.gradleProperty("minSdkOverride").orNull?.let {
    minSdk = it.toInt()
    applicationIdSuffix = ".api$it"
}

モデルは一番小さい組み合わせ(openai-whisper-tinyとqwen2.5-0.5b-instruct)にしました。

項目 結果
SDKの初期化・カタログ取得・ダウンロード 問題なし(Android 12でも動いた)
2つのモデルを読み込んだあとの空きメモリ 約220MB
約8秒の日本語音声の文字起こし 約7.1秒(ほぼ実時間)
「こんにちは」への返事 約5.0秒
グラスのマイクでの会話 動作OK

いくつか気づいた点です。

  • Rokidグラスの実装では何も設定しないと、数秒で画面が消えます。FLAG_KEEP_SCREEN_ONで防ぎました
  • 録音の入力としてVOICE_RECOGNITIONを指定すると、無音しか取れませんでした。MICに切り替えると、話し声で最大レベル約1700、無音時で13〜22と、十分な音量が取れました
  • 精度はtinyなのでお察しな状態になります

スマホで推論する構成と比べると、遅く精度も落ちます。それでもスマホなしで、2GB RAMのグラスだけで音声アシスタントが成立したのは面白い結果でした。

実装、実行時のポイント

IPCモードでモデルを共有できるのは、同時につないでいないときだけ

IPCモードでは、複数のアプリが1つのFoundry Local Appを使います。モデルも1か所にまとまるものの注意が必要なです。ROG Phoneでcompanionを動かしたままRokidのスマホアプリを起動すると、同じモデルをそのまま使うと思いきや、Rokid側が同じモデルをもう一度ダウンロードし始めました。

Foundry Local Appのログを見ると、つないできたアプリを次のように扱っていました。

つなぎ方 推論する場所 モデルの保存先
最初につないだアプリ Foundry Local Appの中 共通の場所(files/models_cache/)
他のアプリがつないでいる間につないだアプリ アプリごとに隔離された専用のワーカー アプリごとの場所(models_cache/clients/uid_<番号>/)
connectWorker: UID 10470 is first client, returning null (in-process)

専用のワーカーでは、アプリごとの場所にモデルが保存されます(Foundry Local Appのログに、ダウンロード先としてmodels_cache/clients/uid_<番号>/が出ていました)。アプリ同士を隔離するための、意図した動きのようです。

ストレージを消してから2つのアプリを1つずつ順番に動かすと、Foundry Local Appのデータは約2.4GBでした。whisper-small(438MB)とqwen2.5-1.5b(1.8GB)を1組だけ持っている量で、呼び出し側の2つのアプリのデータはほぼ0です。同時に使わなければ、モデルは共有されます。同時に動かすアプリが増えるときは、その分のストレージが要ると考えておくのがよさそうです。

モデルのキャッシュは端末間でコピーできる

ダウンロードしたモデルは、アプリのfiles/models_cache/Microsoft/<モデル名>に保存されます。ONNXのモデルはCPUアーキテクチャに依存しないので、x86_64のエミュレータでダウンロードしたキャッシュを、arm64の実機にコピーしてもそのまま読み込めました。ネットワークが不安定な端末で、デバッグ版のアプリにモデルを入れたい場合に便利です。ただし、これはEmbeddedモードの話です。IPCモードではモデルがFoundry Local Appの中にあり、run-asで取り出せません。

# 取り出す(debuggable なアプリのみ)
adb -s <コピー元> exec-out run-as <パッケージ名> tar cf - files/models_cache/Microsoft/<モデル名> > model.tar
# 入れる
adb -s <コピー先> push model.tar /data/local/tmp/
adb -s <コピー先> shell "run-as <パッケージ名> tar xf /data/local/tmp/model.tar"

モデルのサイズは、whisper-smallが438MB、qwen2.5-1.5b-instructが1.8GBでした。初回のダウンロードはWi-Fiで数分かかります。

まとめ

  • Foundry Local for Androidを使うと、AndroidベースのXRデバイスで、文字起こしと会話をクラウドなしで動かせます
  • Embeddedモードは、Google PlayもGMSもないMeta Quest 3でも動きました。Galaxy XRはEmbeddedとIPCの両方で動き、速度は同じでした
  • Microsoft Foundryとの接点はモデルの配布元であるFoundry Catalogです。推論は端末だけで完結し、Azureのアカウントも要りません
  • v0.1.6はCPUのみです。Google Play版のFoundry Local Appも中身は同じエンジンで、カタログもCPU用だけでした。ヘッドセットのCPUではWhisper baseと1.5Bのチャットモデルあたりが実用の上限でした
  • 日本語のライブ文字起こしはSDKに用意されていないので、VADとWhisperで自分で組みます。VADの調整はデバイスのマイクごとに必要でした
  • グラス型のデバイス(MiRZA / Even G2 / Rokid)は、スマホで推論する構成で実用的な速さになりました。Even G2とRokidのスマホアプリはIPCモードでも動きました
  • IPCモードで複数のアプリから使うと、同時につないだアプリはアプリごとに隔離され、モデルも別々に持ちます。順番に使うなら、モデルはFoundry Local Appに1組だけになり容量を節約できます
  • 実験として、Android 12・RAM 2GBのRokidグラス単体でも、最小のモデルで文字起こしと会話が動きました(minSdkを外すサポート外の方法です)

NPUが使えるようになれば、ヘッドセット単体でもWhisper smallやもっと大きなチャットモデルを使えるはずです。今後のリリースに期待しています。

参考資料

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?