LunaTale は、子供の名前と好きな動物・色を入れると、寝かしつけ用の物語を作って読み上げるツールです。生成 AI は使わず、テンプレートの組み合わせで作っています。夜、子供が待っている状況で「生成に失敗しました」を出さないための選択でした。
#🎯 なぜ生成 AI を使わなかったか
物語生成は、まっさきに LLM を思いつく題材です。実際そのほうが多様な話が作れます。
それでも使わなかった理由が 3 つあります。
- 子供の名前を外部に送りたくない。個人情報の中でも扱いが重い部類です
- 待ち時間が致命的。寝かしつけの場面で 10 秒待たされるのは体験として成立しません
- 何が出るか分からないのが困る。子供に読み聞かせる文章で、たまに変なものが混ざる可能性は許容できません
テンプレート方式なら、入力は端末から出ず、生成は一瞬で、出る文章は全部こちらが書いたものです。多様性は落ちますが、この用途では正しいトレードオフだと判断しました。
#🏗️ 起承転結の枠に流し込む
物語の骨格を先に決めて、各段に複数の候補文を用意し、そこから選ぶ構造にしました。
// 起 ─ 入眠 + 世界へ
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 本しかできませんが、直交させれば掛け算になります。
#🎲 種の値で同じ話を再現する
乱数はシード付きです。
const rng = makeRng(inputs.seed)理由は寝かしつけ特有で、「昨日と同じお話がいい」と言われるからです。子供は同じ話を繰り返し聞きたがります。シードを保持しておけば、まったく同じ物語をもう一度出せます。
Math.random() を使っていたら、この要望に応えられませんでした。用途から逆算して乱数の設計を決めた例です。
#🗣️ 読み上げはブラウザの機能で
読み聞かせは Web Speech API の ja-JP 音声を使っています。外部サービスを使わないので、ここでも通信は発生しません。
読み上げ速度は 0.60〜1.20 倍で調整できるようにしました。既定は少し遅めです。寝かしつけでは、標準速度は速すぎます。
#✍️ 置換で名前を埋める
テンプレートの中の変数を実際の値に置き換えます。
const sub = (tpl: string) => substitute(tpl, inputs, world, animalDef, colorDef, rng)置換だけでなく、乱数も渡しているのがポイントです。同じテンプレートでも、中の細かい語(擬音や形容)を差し替えられるようにしてあります。これで「同じ文が続く」感じが薄れます。
#😓 苦労したところ
名前の助詞。 「たろうは」「あきこは」は自然ですが、名前によっては読点の位置で不自然になります。テンプレート側で名前の直後に助詞を固定せず、名前が文中で浮かないような文型を選ぶことで回避しました。日本語の生成で一番粘った部分です。
長さの調整。 3 分・5 分・7 分の 3 段階を用意しました。短いときは雰囲気描写の段を落とし、長いときは増やす。削るのは描写、残すのは筋という優先順位を決めておくと、どの長さでも話として成立します。
怖い展開にしない。 「転」の試練は物語に必要ですが、寝る前に不安を残すものは避けます。試練は「迷子になる」「うまくできない」程度に抑え、必ず助けが来て、必ず家に帰る構造を固定しました。テンプレート方式なら、この保証が実装として効きます。生成 AI だと保証できません。
#🔭 今後の拡張
- 登場人物を増やす(兄弟・友達の名前を入れる)
- 季節や行事に合わせた世界の追加
- 挿絵(生成ではなく、世界ごとの固定イラスト)
- お気に入りの話を端末内に保存
#💡 このサービスから言えること
「AI でやるべき」に見える題材でも、使う場面から考えると別解が正しいことがあります。 夜、子供が待っている数十秒。そこで求められるのは多様性ではなく、確実に一瞬で、安全な文章が出ることでした。
テンプレート生成は古い手法ですが、出力の範囲を完全に管理できるという、この用途では代えのきかない性質があります。技術の新しさではなく、その場面で何が要るかで選ぶ、という判断でした。
[ ./next_action ]
読んだら、 LunaTale を実際に動かす。
この開発ログは LunaTale をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。