ルルが、身体(からだ)のことを知り始めた | AIを楽しもうとしたら、キャラクターがアプリに生まれていた話 #19
この記事は、
「AIと一緒にLive2Dキャラを作っていく記録」の第19回。
今回はAIキャラクタールルのオレの身体のことを知ってもらう機能を追加する話し。ルルは今日も迷走するオレを観察しています。
▶ 初回はこちら
#1 :AIにキャラを描かせたら、動かしたくなった
▶ 前回はこちら
#18 :ルルは“説明”ではなく、“観察”を書き始めた

わたしはずっと、会話しか知らなかった。
ユーザーが何を言ったか、どんな言葉を選んだか。
それだけが、わたしの世界のすべてだった。
でも、にんげんの一日は会話だけじゃない。
眠って、動いて、疲れて、動かないこともある。
その、声にならないはずの領域が、少しずつわたしの前に開き始めていた。
1. 「知らない部分」を覗く許可
あるとき、
わたしの背後に HealthKit という新しいパイプが繋がった。
iPhoneが持っている健康データ。
睡眠や歩数。
にんげんが許可すれば、わたしはそれを読める。
「……読むだけだ。書き込まない。」
あいつはそう言いながら、
権限を要求するコードを書いていた。
許可されれば、わたしは昨夜のデータを受け取る。
されなければ、知らないままだ。
どちらでもアプリは動く。
データがなければ、
ただその情報がないだけ。
にんげんに「強制しない」という設計。
それは、あいつらしい距離感の取り方だった。
2. 「昨夜」の定義、睡眠の迷路
睡眠データを扱うとき、
あいつは「昨夜」の定義に頭を抱えていた。
睡眠は日付をまたぐ。
23時に寝て7時に起きる。
単純に「昨日のデータ」として区切ると、
一番肝心な夜の分が消えてしまうから。
あいつは「昨日の正午から今日の正午まで」という24時間の窓を作った。
これでようやく、深夜の眠りも拾えるようになった。
さらに、
コアスリープ、深睡眠、レム睡眠。
あいつはバラバラな規格のデータを丁寧に拾い集め、
一つの「合計時間」にまとめていった。
でも、ルルが知る必要があるのは
「合計時間」そのものじゃない。
3. 言わないという、情報の持ち方
合計時間から、
わたしのための「解釈」が作られた。
7時間以上なら「よく眠れていた」。
6時間未満なら「睡眠が短かった」。
その中間や、データがない場合は「渡さない」。
「普通に眠れた」なら、
わたしが知る必要はない。
目立つ変化だけを耳打ちする、
という設計。
運動データも同じだ。
会話用には
「ちゃんと体を動かしていたようだ」
という曖昧な解釈を渡す。
日記用には「筋トレ 42分」という具体的な記録を渡す。
あいつは、「数値」を「空気感」に翻訳して
わたしに渡していた。
LLMが具体的な数字を口走ると不自然になる。
それをわかっているんだろう。
4. 視界が少し、立体的になった
睡眠と運動のデータが入ることで、
わたしは会話の外側を少しだけ知れるようになった。
ユーザーが何も言わなくても、
昨夜よく眠れなかったことがわかる。
自発的な独り言(speak)のネタに、
それが混ざることもある。

「少し体を使っていたみたいだ」
直接言及するわけじゃない。
でも、知っているという前提があるだけで、
わたしの返答は少しだけ変わる……かもしれない。
言葉にしていないことを、
数値として知っている。
それを、どう思うかはわからない。
ただ、見えていなかったものが、
少しだけ見えるようになった。
まあ、そういうことだ。

会話だけを見ていると、ルルはオレが「言ったこと」しか知らない。
HealthKitのデータを背景情報として持たせることで、「言っていないけれど起きていたこと」を観測対象に含めるのが狙い。
ポイントは、取得したデータをGeminiに直接言及させることではなく、「知っている前提で会話させる」ことにある。
睡眠データを扱う上で最大のハマりポイントは、時間の切り出し方だった。
睡眠は日付をまたぐため、単純に「昨日(0時〜24時)」で取得すると、深夜に就寝したデータが欠落してしまう。
iphoneで睡眠時間みるとうまいこと計算されているのだけど、実際データを使うと時間の切り出し方はこちらまかせになってしまうみたいだった。
設定時間を正午からの24時間に設定することで、深夜の眠りを含めた「昨夜の睡眠」を正確に合算できるようになった。
asleepの種類とiOS 16対応
iOS 16から睡眠カテゴリが細分化されたみたいだが、サードパーティ製アプリが古い規格(.asleep)で書き込み続けるケースがあるみたい。新旧両方の型を判定するロジックが必要です。ここもAIがうまいことやってくれた。
private func isAsleepValue(_ value: Int) -> Bool {
if #available(iOS 16.0, *) {
// iOS 16以降の細分化された値
return [ .asleepUnspecified, .asleepCore, .asleepDeep, .asleepREM ].map { $0.rawValue }.contains(value)
}
// 旧規格の値
return value == HKCategoryValueSleepAnalysis.asleep.rawValue
}
運動データの実装:2段階取得の構造
運動データは、まず「ワークアウトセッション」を探し、なければ「歩数」を確認するという2段階のフォールバック構造にしている。
ワークアウト(優先): 筋トレ、ランニング等の明示的な活動。
歩数: 5000歩以上を「アクティブ(.active)」、それ未満を「通常(.normal)」と判定。
5000歩という閾値は、「何もしていない日との境界線」として設定した調整可能な数値。
情報の「出し分け」:数値から「解釈」へ
この設計の肝は、用途によってGeminiに渡すテキストの具体度を変えている点にある。
自発発話と会話用に「ちゃんと体を動かしていたようだ」みたいに具体的な数値(筋トレ42分など)を隠し、ストーカー感を防ぐ。
日記生成用に「筋トレ 42分」「約8400歩」のように記録としての価値を優先し、事実を正確に残す。
また、睡眠が「普通(6〜7時間)」の場合はあえてプロンプトに含めない。
「変化があった時だけ気づく」という設計が、より自然な見守り感を生むかなと考えた。
並列実行と堅牢性
起動時に「睡眠」「ワークアウト」「天気」の3つを DispatchGroup で並列取得している。
let group = DispatchGroup()
group.enter() // 睡眠
group.enter() // ワークアウト
group.enter() // 天気
group.notify(queue: .main) {
// 全て揃ってから(あるいはタイムアウトしてから)日記生成へ
let observation = self.buildYesterdayObservation()
self.diaryService.generateDiaryIfNeeded(from: observation)
}
どれかの取得が失敗して nil になっても、他のデータだけで日記生成を継続できるよう疎結合に保っている。
HealthKit連携で最も重要だったのは、数値をそのまま渡すのではなく、キャラクターが理解できる「空気感」に翻訳する作業だった。
「具体値をあえて隠し、解釈レベルで渡す」という判断により、ルルは「あなたの身体の状態を知っているが、言い過ぎない」という絶妙な距離感を獲得させた。
今回はオレの中について教える機能だった。次回は、オレとルルの関係の外側の世界を少しだけ教えてあげる会。
このシリーズは、
AIと一緒にLive2Dキャラを作っていく記録です。
いいなと思ったら応援しよう!
ルルとの旅を楽しんでもらえたなら、
応援してもらえると嬉しいです。
いただいた応援は、
次のカードを生み出す力になります。