本記事は temi × ローカル LLM シリーズの 5 本目です。
自律走行ロボット temi V2 に搭載する Android アプリを Kotlin で実装しました。ローカル LLM と会話し、登録地点へ移動させるアプリです。
前回は、LLM の出力を backend 側で検証し、ロボットを安全に動かすための allowlist や地点名の完全一致チェックについて説明しました。今回は、その backend と通信して実際に temi を動かす Android アプリ側の実装を扱います。
Temi SDK の API 自体は比較的分かりやすい一方で、発話・移動・人検知が同時に動くため、一般的な Android アプリとは異なる状態管理が必要でした。この記事では API 一覧ではなく、次の実装上の課題を中心に扱います。
- 発話をキューで管理する
- 発話完了と移動開始を同期する
- 人検知と自動移動の競合を避ける
- listener の登録と解除を適切に管理する
- ストリーミングイベントを LiveData で欠落させない
日本語の情報が少ない領域なので、詰まった箇所とその対処をまとめます。
0. 前提
| 項目 | 値 |
|---|---|
| Temi SDK |
com.robotemi:sdk:1.137.1(mavenCentral、追加リポジトリ不要) |
| 言語 | Kotlin |
compileSdk / minSdk / targetSdk
|
34 / 23 / 30 |
| Java | 11 |
| UI | viewBinding(Compose・DataBinding は不使用) |
| 実機 |
temi V2(rk3288 / Android 6.0.1) |
minSdk 23 は、今回使用した temi V2 の Android 6.0.1(API 23)に合わせています。targetSdk は実機の OS バージョンそのものを指定するものではなく、アプリがどの API レベルの動作仕様に合わせて作られているかの宣言です。targetSdk 30 は、利用する Android API や Temi SDK との互換性を考慮して設定しています。V2 と V3 では Android のバージョンが異なるため、両対応させる場合は最小対応 API(minSdk)や SDK の互換性を確認する必要があります。
なお撮影機能(CameraX)もコード上は実装していますが、実機での撮影は行っていないため本記事では扱いません。
導入
# gradle/libs.versions.toml
[versions]
temiSdk = "1.137.1"
[libraries]
temi-sdk = { group = "com.robotemi", name = "sdk", version.ref = "temiSdk" }
// app/build.gradle
implementation libs.temi.sdk
settings.gradle の repositories は google() と mavenCentral() だけです。JitPack 等の追加は不要でした。
listener は OnRobotReadyListener / AsrListener / TtsListener / 検知系 / OnLocationsUpdatedListener / OnGoToLocationStatusChangedListener / WakeupWordListener など11個あり、TemiRobotController という1クラスにまとめて実装しています。
1. speak は発話キューを持たない
ストリーミング発話を実装するなら最初に確認すべき仕様です。
今回のtemi V2・SDK 1.137.1の検証では、発話中に speak() を呼ぶと、先行発話が中断される挙動を確認しました。ほかのSDK版・端末でも同じとは限らないため、使用環境で確認してください。
Temi SDK の speak は、既に発話中でも中断して新しい発話を始めます。キューイングしてくれません。
ストリーミングで受け取った文を「できた端から robot.speak() する」と書くと、後続のチャンクが前のチャンクの発話を次々に中断していきます。文単位で逐次発話するには、アプリ側でキューを持つ必要があります。
対処:発話キューを自前で持つ
// temi の speak は前の発話を中断するため、まとめて speak せず「前のチャンクの発話完了
// (onTtsStatusChanged COMPLETED) を待ってから次のチャンクを発話する」逐次キュー方式にする。
fun speakStreamingChunk(text: String) {
if (!robot.isReady || text.isBlank()) return
streamingSpokeAnything = true
streamingChunks.addLast(text)
if (streamingCurrentTtsId == null) {
speakNextStreamingChunk()
}
}
private fun speakNextStreamingChunk() {
val next = streamingChunks.removeFirstOrNull()
if (next == null) {
streamingCurrentTtsId = null
if (streamingFinished) runStreamingPendingAction()
return
}
val request = TtsRequest.create(next)
streamingCurrentTtsId = request.id
activeSpeechRequestId = request.id
robot.speak(request)
}
TtsRequest.create() が返す request.id(UUID)を保持しておき、onTtsStatusChanged で自分の発話かどうかを判定します。他の発話リクエスト(例えば別の会話ターンや割り込み発話)の完了通知を、自分のチャンクの完了と誤認しないようにするためです。
override fun onTtsStatusChanged(ttsRequest: TtsRequest) {
// TtsRequest.Status.COMPLETED / ERROR / CANCELED / NOT_ALLOWED の4値で分岐
}
次のチャンクへ進む契機は COMPLETED だけではありません。ERROR や CANCELED、NOT_ALLOWED でもキューが停止しないよう、後続処理は共通化しています。
2. 発話完了と移動開始を同期する
speak() と goTo() を続けて呼ぶと、発話の完了を待たずに移動が始まります。発話が完了する前に移動を開始するため、案内として不自然な挙動になります。例えば「受付までご案内します」と発話している途中で、移動を開始することがあります。
案内アプリとしては、声をかけてから動き出す方が自然です。
対処:TTS の COMPLETED を待ってから goTo
fun speakAndGoTo(text: String, destinationLabel: String?) {
// pendingTtsRequestId / pendingGoToLocation を保持し、
// TTS が COMPLETED になってから robot.goTo(pendingLocation) を呼ぶ
}
移動完了は OnGoToLocationStatusChangedListener で受けます。
override fun onGoToLocationStatusChanged(
location: String, status: String, descriptionId: Int, description: String,
) {
// OnGoToLocationStatusChangedListener.COMPLETE / .ABORT を判定
}
ABORT は手動で止められた場合などに来ます。ここで状態をリセットしないと、次の案内がおかしくなります。
発話キューと移動の関係を状態遷移として整理すると、pendingTtsRequestId と pendingGoToLocation の役割が見えやすくなります。
3. AI 応答待ちの移動を停止する
ローカル LLM の応答は数秒〜十数秒かかります。その間、temi が自律的に充電位置へ戻り始めることがありました。
目の前で質問を受けたのに、応答前にロボットが移動を開始してしまうわけです。
対処:応答待ちの間は移動を止める
// 待機中は人検知あいさつ/ウェイクワードでの会話の再トリガーを止め、
// GREET モードなどの自律移動で充電位置へ動き出さないよう、
// いったん移動を停止して待つ。
fun setAwaitingAiResponse(awaiting: Boolean) {
if (awaitingAiResponse == awaiting) return
awaitingAiResponse = awaiting
if (awaiting && robot.isReady) {
runCatching { robot.stopMovement() }
callbacks.onTemiStatusChanged("Temi待機: AI応答待ちのため、移動と人検知トリガーを一時停止します。")
}
}
Fragment 側で render(state) から setAwaitingAiResponse(state.isLoading) を呼びます。ローディング状態とロボットの停止状態を連動させる、という発想です。
stopMovement() は現在の移動を止める処理であり、それ単体では「再び動き出さない」ことまでは保証しません。移動を再びトリガーし得る側、つまり人検知あいさつやウェイクワードによる会話の再トリガーも合わせて止めておく必要があります。awaitingAiResponse フラグは、その人検知やウェイクワードの二重起動ガードにも使っています。
4. GREET モードによる人への追従を制御する
今回使用した環境では、Mode.GREET(インタラクションモード)にすると、検知した人へ正対・追従する挙動が確認されました。
人への追従は本来の用途では有用ですが、案内アプリでは意図しない方向転換につながります。近くを人が通るたびにロボットが向きを変えてしまうためです。
対処:人検知あいさつ OFF のときは Mode.DEFAULT
runCatching {
robot.setDetectionModeOn(detectionNeeded, AUTO_GREETING_DISTANCE_METERS)
}
// GREET(インタラクション)モードは temi が検知した人へ正対・追従する挙動の本体。
// 人検知あいさつ OFF のときは DEFAULT にして追従させない(ハンズフリー会話でも追従はさせない)。
runCatching {
robot.setMode(if (autoGreetingEnabled) Mode.GREET else Mode.DEFAULT)
}
setDetectionModeOn(true, distance) で人の検知だけは有効にしつつ、モードは DEFAULT にする。これで「人が来たのは分かるが、追従させない」という状態になります。
人検知のしきい値はこうしました。
private const val AUTO_GREETING_DISTANCE_METERS = 1.8f
private const val AUTO_GREETING_MAX_ANGLE_DEGREES = 45.0
private const val AUTO_GREETING_COOLDOWN_MS = 15_000L
override fun onDetectionDataChanged(detectionData: DetectionData) {
if ((!autoGreetingEnabled && !handsFreeConversationEnabled) || !detectionData.isDetected) return
if (hasActiveDetectionTarget) return
if (detectionData.distance > AUTO_GREETING_DISTANCE_METERS) return // 1.8f
if (abs(detectionData.angle) > AUTO_GREETING_MAX_ANGLE_DEGREES) return // 45.0
if (manualConversationActive || activeSpeechRequestId != null ||
pendingGoToLocation != null || awaitingAiResponse) return
val now = System.currentTimeMillis()
if (now - lastGreetingAtMs < AUTO_GREETING_COOLDOWN_MS) { // 15_000L
hasActiveDetectionTarget = true
return
}
hasActiveDetectionTarget = true
lastGreetingAtMs = now
// ...
}
距離・角度・クールダウン・他状態の4重ガードです。
hasActiveDetectionTarget を true に戻す条件は上のコードのとおりですが、false に戻す箇所も別途必要です。今回の実装では、人検知あいさつ/ハンズフリー会話を OFF にしたとき、および temi 側の検知コールバックが対象消失(LOST)や検知なし(IDLE)を報告したときにリセットしています。ここを入れ忘れると、最初の検知以降ずっと true のままになり、新しい挨拶が発生しなくなります。
5. listener の登録と解除でメソッド名が異なる
これは名前だけでは気づきにくい注意点です。
robot.addOnLocationsUpdatedListener(this) // Updated
robot.removeOnLocationsUpdateListener(this) // Update ← "d" が無い
登録は Updated、解除は Update。 SDK のタイポではなく実際の API 名で、公式ドキュメントにもこの表記で載っています。
IDE の補完で removeOnLocationsUpdatedListener を探しても出てこないので、「解除メソッドが無い?」と思うことになります。listener の登録・解除は Fragment の onStart() / onStop() から行い、stop() では解除に加えて setDetectionModeOn(false, ...) と setMode(Mode.DEFAULT) も行って、アプリが前面にいないときにロボットが人検知で動かないようにしています。
listener の解除漏れは、単なるメモリリークだけでなく、同じイベントに複数回反応する原因にもなります。Fragment が再生成されるたびに listener が重複登録されると、1回のイベントに対して発話や移動が複数回走ることがあります。発話や移動が二重に起きる場合は、まず listener の登録回数を疑って確認します。
6. LiveData で発話チャンクが欠落する
ストリーミングで受け取った文を LiveData 経由で Fragment に渡していたら、一部の文が発話されませんでした。
原因は postValue です。短時間に複数回 postValue すると、メインスレッドに配送される前に値が上書きされて合体します。LiveData は「最新の状態」を配るものであって、「すべてのイベント」を配るものではありません。
高頻度で流れてくる発話チャンクは、この性質と致命的に相性が悪いです。
対処:コールバックで直接渡す
// 高頻度の発話チャンクは LiveData だと postValue が合体し欠落するため、
// コールバックで直接渡す。コールバックをメインスレッドへ切り替えてから呼び出す。Fragment 破棄時に必ず null に戻すこと。
var onAssistantStreamBegin: (() -> Unit)? = null
var onAssistantSpeechChunk: ((String) -> Unit)? = null
Fragment の onDestroyView() で null に戻すのを忘れるとリークします。この実装ではコールバックをメインスレッドに切り替えてから呼び出しています。SDK のコールバック自体がどのスレッドで呼ばれるかは仕様として明記されていないため、呼び出し元のスレッドに依存させず、自分の実装側で明示的に切り替える形にしています。
用途ごとに適した方法を整理すると、次のようになります。
| データの種類 | 適した方法 |
|---|---|
| 画面に表示する現在状態 | LiveData / StateFlow |
| 発話チャンク | コールバックや Channel |
| 一度だけ処理するイベント | version 管理やイベントラッパー |
| 高頻度ストリーム | queue、Channel、SharedFlow など |
「一度きりのイベント」は version 方式で
LiveData で状態は流すが「1回だけ処理したい」ものは、version 番号とペアにしました。
// pendingTemiSpeechText / pendingTemiSpeechVersion
// pendingStreamingFinal / pendingStreamingFinalVersion
Fragment が処理したら markPendingTemiSpeechHandled(version) を呼んでクリアします。SingleLiveEvent の自前実装のようなものです。
7. 音声入力(ASR)― temi 標準アシスタントとの競合
temi マイクの使い方
// 抜粋(isReady ガードや状態フラグの更新は省略)
fun startListening() {
finishConversationSafely() // runCatching { robot.finishConversation() }
robot.setAsrLanguages(listOf(SttLanguage.JA_JP))
robot.askQuestion(
TtsRequest.create("ご用件をどうぞ"),
SttRequest(listOf(SttLanguage.JA_JP), 8, false),
)
}
結果は Robot.AsrListener で受けます。
override fun onAsrResult(asrResult: String, sttLanguage: SttLanguage) {
// asrResult がテキスト
}
startListening() の頭で finishConversation() を runCatching 付きで呼んでいるのがポイントです。前の会話セッションを確実に閉じてから新しい聞き取りを始めます。
会話状態を監視して受信できなかった結果を補完する
override fun onConversationStatusChanged(status: Int, text: String) {
// IDLE / LISTENING / THINKING / SPEAKING
}
IDLE に遷移したタイミングで、onAsrResult は受け取ったものの即時確定に至らなかった transcript を拾い直すフォールバックを入れています。pendingManualTranscript が残っていれば、そこで確定させます。
端末 ASR との二重の入力経路
temi が ready でないとき用に、Android の SpeechRecognizer も使えるようにしました(temi マイク優先、ダメなら端末 SpeechRecognizer、それも無ければ「利用できるマイクがありません」)。2つの入力経路を用意したことで、temi が利用できないエミュレータや通常の Android 端末でも開発を継続できました。destroy() の呼び出しや View 破棄時のリスナー解除、temi 側 ASR との同時起動回避など、後始末にも注意が必要です。
ウェイクワード周辺
override fun onWakeupWord(wakeupWord: String, direction: Int, origin: WakeupOrigin) {
// handsFreeConversationEnabled が true のときだけ startListening()
}
二重起動ガードは次の条件です。
manualConversationActive || activeSpeechRequestId != null ||
pendingGoToLocation != null || awaitingAiResponse
なおコード上にはこういうコメントがあります。
// temi 純正ウェイクワード(ConversationMediator)は常に無効化する。
// 非Kioskで有効化すると temi 純正アシスタントの audio focus が競合し、アプリの
// askQuestion(聞き取り)が機能しなくなるため。会話はアプリ側で開始する。
runCatching { robot.toggleWakeup(true) }
経緯としては、temi 純正のウェイクワードとアプリ側の聞き取りが競合したため、純正側を無効にする設定にした、というものです。temi 標準のアシスタントが起動してしまい、アプリ側の askQuestion が機能しなくなる形になっていました。
いずれにせよ、temi 標準のアシスタントと自作アプリの会話機能は audio focus が競合するということは押さえておく必要があります。自作アプリで会話を制御するなら、標準側は止めておく前提で組んだ方が安全です。
8. HttpURLConnection による通信実装
今回は、OkHttp、Retrofit、Gson、Moshi などの外部ライブラリを使用せず、java.net.HttpURLConnection と org.json だけで実装しました。
必要な通信は3つのエンドポイントと NDJSON の逐次読み取りに限られており、追加ライブラリを導入する必要性が低いと判断したためです。ただし外部ライブラリを使わない分、次の点は自分で気をつける必要があります。ネットワーク処理はメインスレッドで実行しない、接続・読み込み・切断は finally で処理する、HTTP 4xx/5xx 時は errorStream を読む、キャンセル時は接続を切る、Activity/Fragment 破棄後にコールバックを呼ばない、といった点です。NDJSON は1行1 JSON なので、inputStream.bufferedReader().forEachLine { ... } で delta/final/error を素直に読み分けられます。
read timeout 330 秒の理由
ローカル LLM の応答は重く、backend 側は REQUEST_TIMEOUT_SECONDS=300 で待ちます。アプリ側を backend より 30 秒長い 330 秒にしているのは、backend のタイムアウトエラーをアプリが受け取れるようにするためです。
アプリ側が先に切れると、何が起きたのか分からないまま接続が落ちます。
final が来なければ例外
これは意図的です。final が届かなければ物理アクションは実行しません。ただし、delta を受け取った時点で発話はすでに始まっている可能性があります。そのため「発話の中断」と「移動の禁止」は別々に扱っており、final 未着時に保証しているのは後者だけです。
クライアント側でも同じ判定をする
continueConversation が返ってこなかった場合のフォールバックを、backend と同じロジックで実装しています。
actionType != "goTo" && actionType != "takePhoto" && reasonCode != "conversation_finished"
9. 地点名は完全一致した場合のみ採用する
backend が返す destinationId を、検証せずに robot.goTo() へ渡すべきではありません。
// SlideshowViewModel(抜粋・null/blank ガードは省略)
fun resolveDestinationLabel(destinationId: String?): String? =
destinationId?.takeIf { temiLocations.contains(it) }
temi の登録地点名と完全一致するときだけ採用します。
backend 側にも同じガードがあり、一覧にない destinationId は none に降格させています。それでも二重にしているのは、LLM が「受付前」「受付エリア」のような惜しい名前を返したときに、アプリ側が独自に解釈して別の場所へ行かないようにするためです。
地点一覧は onRobotReady と onLocationsUpdated で取得します。
override fun onRobotReady(isReady: Boolean) {
// robot.locations(List<String>)を取得してコールバック通知
}
override fun onLocationsUpdated(locations: List<String>) {
// 更新時に再通知
}
ホーム復帰も同じ考え方です。ホーム地点として指定した名前が登録地点に存在しない場合は、指定地点への移動を行わず、repose() を呼び出します。repose() は、今回の構成では「登録地点が見つからないときのホーム復帰の代替処理」として使っています。ただし goTo() で特定の登録地点へ移動するのと同じ意味の API ではないため、運用上どの地点へ戻るかは temi 本体側の設定に依存する点に注意が必要です。
参考
- temi 公式 GitHub: https://github.com/robotemi
- Temi SDK: https://github.com/robotemi/sdk
- SDK の API 一覧: https://github.com/robotemi/sdk/blob/master/docs/sdk/com.robotemi.sdk/-robot/index.md
- SDK Wiki: https://github.com/robotemi/sdk/wiki
- サンプルアプリ: https://github.com/robotemi/sdk/tree/master/sample
- temi アプリ開発の記事: https://medium.com/@kathybuilds/robotics-for-good-building-an-app-for-temi-the-robot-ffd956f28529
まとめ
シリーズはこれで全5本です。最後までお読みいただき、ありがとうございました。
じゃんけん…ポン!
✋
最後はパーでした。