AI Dev Lab
LunaTale

LunaTale ができるまで — 寝かしつけ物語を自動生成する

寝かしつけ物語ジェネレーターの開発ログ。 子供の名前と好きな動物から起承転結の物語を組み立て、 種の値で同じ話を再現できるようにし、 ブラウザの読み上げ機能で朗読させるまでを記録しています。

LunaTale は、子供の名前と好きな動物・色を入れると、寝かしつけ用の物語を作って読み上げるツールです。生成 AI は使わず、テンプレートの組み合わせで作っています。夜、子供が待っている状況で「生成に失敗しました」を出さないための選択でした。

#🎯 なぜ生成 AI を使わなかったか

物語生成は、まっさきに LLM を思いつく題材です。実際そのほうが多様な話が作れます。

それでも使わなかった理由が 3 つあります。

  1. 子供の名前を外部に送りたくない。個人情報の中でも扱いが重い部類です
  2. 待ち時間が致命的。寝かしつけの場面で 10 秒待たされるのは体験として成立しません
  3. 何が出るか分からないのが困る。子供に読み聞かせる文章で、たまに変なものが混ざる可能性は許容できません

テンプレート方式なら、入力は端末から出ず、生成は一瞬で、出る文章は全部こちらが書いたものです。多様性は落ちますが、この用途では正しいトレードオフだと判断しました。

#🏗️ 起承転結の枠に流し込む

物語の骨格を先に決めて、各段に複数の候補文を用意し、そこから選ぶ構造にしました。

ts
// 起 ─ 入眠 + 世界へ
const openingPara = sub(pick(OPENINGS, rng))
const enterPara = sub(pick(ENTER_WORLD, rng))
const worldIntroPara = sub(pick(world.intro, rng))

// 承 ─ 出会い + 案内
const meetPara = sub(pick(MEET_ANIMAL, rng))
const guidePara = sub(pick(FRIENDLY_GUIDE, rng))

// 転 ─ 試練 + 助け + 教訓
const trialPara = sub(pick(theme.trial, rng))
const helperPara = sub(pick(theme.helper, rng))
const lessonPara = sub(pick(theme.lesson, rng))

// 結 ─ 余韻 + 帰還
const resolvePara = sub(pick(RESOLVE, rng))
const returnPara = sub(pick(RETURN_HOME, rng))

コードを読むと物語の構造がそのまま見えるのが、この方式の良いところです。「転」で試練を出し、助けが来て、そこから学びが残る。寝かしつけの話として外してはいけない型を、実装として固定できます。

#🌍 世界とテーマを直交させる

選べるのは 6 つの世界(もり・うみ・そら・おかし・かぜ・ゆき)と 6 つの学び(やさしさ・ゆうき・がまん・たすけあい・こうきしん・しょうじき)です。

重要なのは、世界とテーマが独立していることです。

  • 世界は「導入」と「雰囲気描写」を持つ
  • テーマは「試練」「助け」「教訓」を持つ

だから 6 × 6 = 36 通りの組み合わせが作れます。各段に候補が数本あるので、実際の組み合わせはもっと増えます。

同じ軸に全部を詰め込まないのが、テンプレート生成で多様性を稼ぐコツでした。世界ごとに物語を丸ごと書くと 6 本しかできませんが、直交させれば掛け算になります。

#🎲 種の値で同じ話を再現する

乱数はシード付きです。

ts
const rng = makeRng(inputs.seed)

理由は寝かしつけ特有で、「昨日と同じお話がいい」と言われるからです。子供は同じ話を繰り返し聞きたがります。シードを保持しておけば、まったく同じ物語をもう一度出せます。

Math.random() を使っていたら、この要望に応えられませんでした。用途から逆算して乱数の設計を決めた例です。

#🗣️ 読み上げはブラウザの機能で

読み聞かせは Web Speech API の ja-JP 音声を使っています。外部サービスを使わないので、ここでも通信は発生しません。

読み上げ速度は 0.60〜1.20 倍で調整できるようにしました。既定は少し遅めです。寝かしつけでは、標準速度は速すぎます。

#✍️ 置換で名前を埋める

テンプレートの中の変数を実際の値に置き換えます。

ts
const sub = (tpl: string) => substitute(tpl, inputs, world, animalDef, colorDef, rng)

置換だけでなく、乱数も渡しているのがポイントです。同じテンプレートでも、中の細かい語(擬音や形容)を差し替えられるようにしてあります。これで「同じ文が続く」感じが薄れます。

#😓 苦労したところ

名前の助詞。 「たろうは」「あきこは」は自然ですが、名前によっては読点の位置で不自然になります。テンプレート側で名前の直後に助詞を固定せず、名前が文中で浮かないような文型を選ぶことで回避しました。日本語の生成で一番粘った部分です。

長さの調整。 3 分・5 分・7 分の 3 段階を用意しました。短いときは雰囲気描写の段を落とし、長いときは増やす。削るのは描写、残すのは筋という優先順位を決めておくと、どの長さでも話として成立します。

怖い展開にしない。 「転」の試練は物語に必要ですが、寝る前に不安を残すものは避けます。試練は「迷子になる」「うまくできない」程度に抑え、必ず助けが来て、必ず家に帰る構造を固定しました。テンプレート方式なら、この保証が実装として効きます。生成 AI だと保証できません。

#🔭 今後の拡張

  • 登場人物を増やす(兄弟・友達の名前を入れる)
  • 季節や行事に合わせた世界の追加
  • 挿絵(生成ではなく、世界ごとの固定イラスト)
  • お気に入りの話を端末内に保存

#💡 このサービスから言えること

「AI でやるべき」に見える題材でも、使う場面から考えると別解が正しいことがあります。 夜、子供が待っている数十秒。そこで求められるのは多様性ではなく、確実に一瞬で、安全な文章が出ることでした。

テンプレート生成は古い手法ですが、出力の範囲を完全に管理できるという、この用途では代えのきかない性質があります。技術の新しさではなく、その場面で何が要るかで選ぶ、という判断でした。

[ ./next_action ]

読んだら、 LunaTale を実際に動かす。

この開発ログは LunaTale をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。

[ ./related_logs ]

関連する開発ログ

all logs →
ToonCast

ToonCast ができるまで — AnimeGANv2 をブラウザで動かす

AnimeGANv2 の小さな ONNX (約9MB) を onnxruntime-web (単一スレッド WASM=COOP/COEP不要、 color-revive で承認済みライブラリの再利用) で実行。 512x512・[-1,1] 正規化で推論し、 結果を元解像度に戻して表示する設計記録。 写真は端末内処理。

read log →
ColorRevive

ColorRevive ができるまで — onnxruntime-web で白黒写真をカラー化

DeOldify の量子化 ONNX を onnxruntime-web (CDN side-load・単一スレッド WASM=COOP/COEP不要) で実行。 256x256 でモデル推論し、 輝度は元写真・色だけ AI を YCbCr で再合成して輪郭を保つ設計記録。 写真は端末内処理。

read log →
PhotoTwin

PhotoTwin ができるまで — CLIP画像埋め込みで似た写真を見つける

CLIP (Xenova/clip-vit-base-patch32) の image-feature-extraction を transformers.js の CDN ESM で side-load し、 各写真を正規化ベクトル化。 cosine 類似度で重複・似た写真をブラウザ内で検出する設計記録 (新ライブラリ追加なし=what-cam と同じ CLIP の再利用)。

read log →
AkinFind

AkinFind ができるまで — ブラウザ内で完結する embeddings 意味検索

多言語の文章埋め込みモデル (Xenova/multilingual-e5-small) を transformers.js の CDN ESM で side-load し、 各文を正規化ベクトル化。 cosine 類似度で意味検索と似ている文ペア検出を全て端末内で行う設計記録。

read log →
WhatCam

WhatCam ができるまで — CLIP のゼロショット画像分類をブラウザで動かす

CLIP (Xenova/clip-vit-base-patch32) を transformers.js の CDN ESM で side-load し、 写真と候補ラベルの近さをブラウザ内で計算。 日本語ラベルを英語プロンプトに変換し、 図鑑と自由入力の両モードで「これ何?」を判定する設計記録。

read log →
DepthCast

DepthCast ができるまで — 1枚の写真をAIの深度推定で立体にする

Depth Anything (transformers.js) を CDN ESM で side-load し、 1枚の写真から深度マップを推定。 WebGL2 フラグメントシェーダで深度に比例した視差 (iterative backward parallax) を作り、 赤青アナグリフ / WebM 書き出しまで端末内で完結させた設計記録。

read log →