ハムスターの「かわいい」を一生涯カウントしてみた
〜JOJOの「パン何枚」になぞって、“かわいい”を数える実験〜
「お前は今までに食ったパンの枚数を覚えているのか?」
— JOJOより
「自分は今までに、愛ハムに“かわいい”って何回言ったんだろう?」
めっちゃ言うからなー🐹
それが“見えたら”面白いかも知らんな
ということで、「かわいいを数えて可視化するガジェット」を作ってみました!
背景:ハム旅(ハムタビ)とつなげたい
私は「ハム旅(ハムタビ)」という、ハムスターの行動や日常ログを集めて“旅”として可視化するアプリを作っています。これと連動できたらおもしろそうだなって思っています。
ハム旅コンセプト(Note記事)
今回実現したこと(要件)
- ケージ前に人が来たら検知(=1生涯で何時間ハムスターのお世話をしたか?の起点としても活用)
- その間の音声から「かわいい」を自動検出してカウント
- 累計を保存し続ける(かわいいといった回数/お世話した時間)
- 1かわいい = 1LED として物理メーター化
- MAX到達でレインボー演出(祝福)
ガジェットの挙動
1.ケージ前に座る
2.ハムスターと戯れる
3.うっかり発した言葉に「かわいい」が含まれていたらカウント
4.LEDが1個増える
5.LEDが全部埋まったら 虹🌈
6.ケージ前にいた時間が **「お世話時間」**として累計される
構成
ハード
- Raspberry Pi 4 Model B
- USBマイク(無駄に、HyperX QuadCast)
- PIR人感センサー(HC-SR501)
- WS2812B LEDテープ
ソフト
- Python3
- Vosk(ローカル音声認識)
- gpiozero(PIR制御用)
- spidev(SPIでNeoPixel駆動)
「かわいい」の認識は、Vosk を利用!
「かわいい」をどうやって自動判定するか、がこのガジェットの肝です。
今回は Vosk というオフライン音声認識ライブラリを使い、
マイクで録音した音声をその場で文字起こしし、
そこから「かわいい」を拾う、という構成にしました。
Vosk を採用した理由
理由はシンプルで
- 日本語対応
- Raspberry Pi で動く
- オフラインで完結する
- Python から扱いやすい
「精度最優先」ではなく、常時動かしても破綻しないことを重視しました。
※ 精度を上げすぎるとモデルが重くなり、常時動作でつらくなったり、
「かわいい」以外の誤検出対策(辞書・フィルタ)が増えて運用が面倒になるので、今回はこのくらいがちょうど良いと判断しました。
ざっくりした使い方
-
arecordで数秒だけ音声を録音 - WAV ファイルを Vosk に渡す
- 認識結果(テキスト)を受け取る
- テキストから「かわいい」を数える
Vosk は音声ストリームをそのまま扱えるので、長時間の音声にも対応できますが、
今回は 短い音声を小刻みに処理する方式にしています。
文字に起こしたものから検索
Vosk で文字起こしした結果は、最終的に ただの文字列として扱えるので、
そこから「かわいい系ワード」だけを拾いました。
import re
def count_kawaii(text: str) -> int:
pattern = r"(かわいい|可愛い|カワイイ|かわいー|かわええ)"
return len(re.findall(pattern, text))
表記ゆれ(ひらがな/カタカナ/漢字)を吸収
精度を上げすぎない(多少の誤認識は許容)
「完璧に聞き取る」より「それっぽく拾えればOKだぜ!」という割り切りです。
運用上のポイント
実際に動かしてみて、重要だった点をまとめます。
常時録音しないことにした
→ 人感センサーで「ケージ前に人がいるとき」だけ処理
長文を一気に認識しない
→ 数秒単位で区切って処理
認識結果は信じすぎない
→ 正規表現で雑に拾う
音声認識は「精度」より「雰囲気」が大事だと感じました。
「かわいい」を物理メーターにする(LED)
次に、可愛いを可視化します。
WS2812BというLEDテープを使った物理メーターとして表現しました。
「かわいい」が1回増えるとLEDが1個増えるだけです
今回は 1:1 にしましたが、あとからレートを変えられるように定数にしたりしました。
テープのハウジングは3Dプリンタでつくりました。

※ WS2812B(NeoPixel)は定番ライブラリが素直に動きませんでした。今回は最終的に SPI 経由で駆動する形に落ち着きました。このあたりはハマりどころが多いので、別記事にするかもしれません。
「お世話時間」の累計
RIPセンサーでケージの前に人が来たことを検知しているので、
「一生涯お世話した時間」も一緒に記録します。
考え方
- ケージ前に人が来たら「滞在開始」
- いなくなったら「滞在終了」
- あまりに短い滞在は無視する
- 有効だった滞在時間を累計する
「お世話=ケージの前にちゃんといた時間」とした感じです。
滞在が終わったら、まとめて加算する
※ PIRは人の微妙な動きで 0/1 が揺れるので、秒単位で正確な滞在時間は取りにくいです。
今回は「だいたい一緒にいた時間が分かればOK」という割り切りで、短すぎる滞在を除外する形にしました。
滞在が終了したタイミングで、その時間をまとめて累計します。
if duration >= MIN_PRESENCE_SEC:
total_presence_sec += duration
1秒ごとに足すのではなく、「1回の滞在=1まとまり」として扱っています。
電源を切っても消えないように、「かわいい」の回数と一緒に JSON で保存しています。
{
"total_kawaii": 123,
"total_presence_sec": 4567.8
}
起動時にこれを読み込み、前回の続きから再開する仕掛けです。
ハムスターの一生涯は短い(2年くらい)ですが、正確に測る必要はなく、だいたいわかればいいかなーって感じです。

おわりに
「かわいい」と「一緒にいた時間」が残ると、ハムスターとの生活がちょっとだけ“記録”になります。
このログ、いずれ ハム旅(ハムタビ) 側にも連動させて、行動ログと一緒に眺められるようにしたいです。
続きも作ったらまた書きます🐹
付録:完成版ソースコード(折りたたみ)
※ コピペして動かしたい方向けに、完成版 hamkawa_auto2.py を載せます。
hamkawa_auto.py(クリックで展開)
#!/usr/bin/env python3
"""
hamkawa_auto.py
- PIR(人感)で「ケージ前に人がいる時だけ」録音・認識する
- Vosk(オフライン音声認識)でテキスト化し、「かわいい」をカウント
- 1かわいい=1LEDとしてWS2812B(NeoPixel)をSPI駆動で積み上げ表示
- ついでに「お世話時間(滞在時間)」も累計してJSONに保存
調整したくなったらまずここ:
- GPIOピン番号: DigitalInputDevice(17)
- 録音デバイス: CARD_DEVICE
- 「録音の間隔」: RECORD_INTERVAL
- 「滞在判定のゆるさ」: MIN_PRESENCE_SEC / PRESENCE_TIMEOUT
- LED本数 & レート: LED_COUNT / KAWAII_PER_LED
"""
import time
import wave
import json
import re
import subprocess
import os
import spidev
from vosk import Model, KaldiRecognizer
from gpiozero import DigitalInputDevice
# ======================
# 基本設定
# ======================
# Vosk日本語モデルのパス(事前にダウンロード&展開しておく)
MODEL_PATH = "models/vosk-model-small-ja-0.22"
# arecord に渡す録音デバイス指定
# `arecord -l` でカード番号を確認して、環境に合わせて変更する
CARD_DEVICE = "plughw:3,0"
# 一時録音ファイル(短いチャンクを繰り返し録る)
WAV_PATH = "hamkawa_chunk.wav"
# 累計保存(電源OFFでも消えないようにね)
STATS_PATH = "hamkawa_stats.json"
# 録音チャンクの長さ(秒)
# 長いほど認識が安定しやすいが、レスポンスは遅くなる。。
RECORD_SECONDS = 3
# 録音を繰り返す間隔(秒)
# ここを短くすると反応は良いがCPU/電力/保存負荷が増える。。
RECORD_INTERVAL = 5.0
# 「お世話時間」にカウントする最小滞在時間(秒)
# 通りすがりなどを弾く目的
MIN_PRESENCE_SEC = 5.0
# PIRが0になっても、少しだけ猶予を持って「滞在継続」とみなす(秒)
# PIRは微妙に揺れるので、これがないと滞在がブツ切れになりがちなの
PRESENCE_TIMEOUT = 10.0
# ======================
# LED(SPI NeoPixel)設定
# ======================
# LEDテープのLED数(好きな数に切れるタイプのテープでした)
LED_COUNT = 10
# 「何かわいいで1LED増えるか」
# 例: 1 -> 1回で1LED / 5 -> 5回で1LED
KAWAII_PER_LED = 1
# SPI設定(Raspberry PiのSPI0を使う想定)
SPI_BUS = 0
SPI_DEV = 0
# SPIクロック(WS2812Bの擬似波形を作るための速度)
# 速すぎ/遅すぎで不安定になることがあるので、動作実績のある値を使うのが吉
SPI_HZ = 2400000
# MAX演出(LEDが満タンになった瞬間にだけレインボー)
RAINBOW_SECONDS = 3.0
RAINBOW_FPS = 25
# ======================
# LED 制御(SPI)
# ======================
def _encode_byte(b: int):
"""
WS2812B は「一定のタイミングの波形」を期待するが、SPIで疑似的に作る。
1bitを3bitに展開し、0/1を波形っぽく表現する方式。
- 1 -> 110
- 0 -> 100
これをビット列にしてSPIで流すことで、NeoPixelを駆動する。
"""
out = []
for bit in range(7, -1, -1):
out.append(0b110 if (b >> bit) & 1 else 0b100)
# 3bit単位のデータを8bitに詰め直す
packed = 0
nbits = 0
res = []
for v in out:
packed = (packed << 3) | v
nbits += 3
while nbits >= 8:
shift = nbits - 8
res.append((packed >> shift) & 0xFF)
nbits -= 8
packed &= (1 << nbits) - 1 if nbits else 0
if nbits > 0:
res.append((packed << (8 - nbits)) & 0xFF)
return res
def _hsv_to_rgb(h, s, v):
"""レインボー演出用:HSV→RGB変換(簡易)"""
i = int(h * 6.0)
f = (h * 6.0) - i
p = v * (1.0 - s)
q = v * (1.0 - f * s)
t = v * (1.0 - (1.0 - f) * s)
i = i % 6
if i == 0:
r, g, b = v, t, p
elif i == 1:
r, g, b = q, v, p
elif i == 2:
r, g, b = p, v, t
elif i == 3:
r, g, b = p, q, v
elif i == 4:
r, g, b = t, p, v
else:
r, g, b = v, p, q
return int(r * 255), int(g * 255), int(b * 255)
class KawaiiLED:
"""
LED表示専用クラス
- update_by_kawaii(): 累計かわいい数から点灯数を決めて表示
- rainbow_fx(): MAX到達演出
"""
def __init__(self, led_count):
self.led_count = led_count
self.spi = spidev.SpiDev()
self.spi.open(SPI_BUS, SPI_DEV)
self.spi.max_speed_hz = SPI_HZ
self.spi.mode = 0
self._max_fx_last = False # 前回MAXだったか(MAX到達した瞬間だけ演出するため)
def show(self, pixels_grb):
"""
WS2812BはGRB順なので (g, r, b) で受け取る。
SPIに流すために疑似波形に変換して送る。
"""
buf = []
for (g, r, b) in pixels_grb:
buf.extend(_encode_byte(g))
buf.extend(_encode_byte(r))
buf.extend(_encode_byte(b))
self.spi.xfer2(buf)
time.sleep(0.001) # ほんの少し待って安定させる
def clear(self):
"""消灯(終了時や例外時に呼ぶと安全)"""
self.show([(0, 0, 0)] * self.led_count)
def update_by_kawaii(self, total_kawaii):
"""
累計かわいい数 → 点灯数に変換して表示
"""
# 1LEDあたりのレートを反映して点灯数を決める
leds_on = min(self.led_count, total_kawaii // KAWAII_PER_LED)
# MAX到達「した瞬間」だけ演出(ずっと虹だと通常表示に戻れないので)
is_max = (leds_on >= self.led_count)
if is_max and not self._max_fx_last:
self.rainbow_fx(RAINBOW_SECONDS)
self._max_fx_last = is_max
# 通常表示(緑の積み上げ)
pixels = []
for i in range(self.led_count):
if i < leds_on:
pixels.append((80, 0, 0)) # 緑(GRB)
else:
pixels.append((0, 0, 0))
self.show(pixels)
def rainbow_fx(self, seconds=3.0):
"""全LEDを虹で流す演出(祝福)"""
start = time.time()
frame = 0
while time.time() - start < seconds:
pixels = []
for i in range(self.led_count):
h = ((i / self.led_count) + (frame / (self.led_count * 3.0))) % 1.0
r, g, b = _hsv_to_rgb(h, 1.0, 0.4) # v=0.4で眩しすぎ防止
pixels.append((g, r, b)) # GRB
self.show(pixels)
frame += 1
time.sleep(1.0 / RAINBOW_FPS)
def close(self):
"""後始末:消灯してSPIクローズ"""
self.clear()
self.spi.close()
# ======================
# 音声関連
# ======================
def count_kawaii(text: str) -> int:
"""
認識結果テキストから「かわいい系ワード」を数える。
音声認識は表記ゆれが起きやすいので、正規表現でざっくり拾う。
"""
pattern = r"(かわいい|可愛い|カワイイ|かわいー|かわええ)"
return len(re.findall(pattern, text))
def record_chunk():
"""
arecordで短時間録音(モノラル16kHz / 16bit)
Vosk(KaldiRecognizer)は16kHzが扱いやすいのでこの設定にしている。
check=True にしているので録音に失敗すると例外で落ちる(デバッグしやすい)
"""
subprocess.run(
[
"arecord",
"-D", CARD_DEVICE,
"-d", str(RECORD_SECONDS),
"-f", "S16_LE",
"-r", "16000",
"-c", "1",
WAV_PATH,
],
check=True,
)
def transcribe_wav(path, model):
"""
WAVファイルをVoskで文字起こし。
- AcceptWaveform() が True になるタイミングで部分結果が取れる
- 最後に FinalResult() も必ず回収する
"""
wf = wave.open(path, "rb")
rec = KaldiRecognizer(model, wf.getframerate())
texts = []
while True:
data = wf.readframes(4000)
if len(data) == 0:
break
if rec.AcceptWaveform(data):
res = json.loads(rec.Result())
if res.get("text"):
texts.append(res["text"])
res = json.loads(rec.FinalResult())
if res.get("text"):
texts.append(res["text"])
return " ".join(texts)
# ======================
# 累計保存
# ======================
def load_stats():
"""
累計値をJSONからロード。
ファイルがなければ初期値(0,0.0)
"""
if not os.path.exists(STATS_PATH):
return 0, 0.0
with open(STATS_PATH, "r", encoding="utf-8") as f:
d = json.load(f)
return d.get("total_kawaii", 0), d.get("total_presence_sec", 0.0)
def save_stats(kawaii, presence):
"""
累計値をJSONへ保存(indent=2で人が読める)
"""
with open(STATS_PATH, "w", encoding="utf-8") as f:
json.dump(
{"total_kawaii": kawaii, "total_presence_sec": presence},
f,
ensure_ascii=False,
indent=2,
)
# ======================
# メイン
# ======================
def main():
print("🧠 モデル読み込み中...")
model = Model(MODEL_PATH) # 初回はここが重い(モデルロード)
# 前回までの累計をロード
total_kawaii, total_presence_sec = load_stats()
print(f"📂 累計: かわいい {total_kawaii} / お世話 {total_presence_sec:.1f}s")
# LED初期表示(起動直後に現在値が見える)
led = KawaiiLED(LED_COUNT)
led.update_by_kawaii(total_kawaii)
# PIR入力(GPIO17)
# 配線を変えたらここを変える
pir = DigitalInputDevice(17)
# 滞在状態管理
presence_active = False
presence_start = None
last_motion_time = None
last_record_time = 0
print("🐹 hamkawa 自動モード開始!Ctrl+Cで終了")
try:
while True:
now = time.time()
v = pir.value # 0/1
# 1) 滞在開始(0→1に入ったタイミング)
if not presence_active and v == 1:
presence_active = True
presence_start = now
last_motion_time = now
# すぐ録音したいので「前回録音時刻」を過去にしておく
last_record_time = now - RECORD_INTERVAL
print("\n👀 ケージ前に来た!")
# 2) 滞在中(1の間)…定期的に録音→認識→カウント
if presence_active and v == 1:
last_motion_time = now
# 録音間隔が来たら録音する
if now - last_record_time >= RECORD_INTERVAL:
last_record_time = now
# 録音→文字起こし→カウント
record_chunk()
text = transcribe_wav(WAV_PATH, model)
k = count_kawaii(text)
# かわいいが見つかったら加算&LED更新
if k > 0:
total_kawaii += k
led.update_by_kawaii(total_kawaii)
print(f"💕 +{k} → 累計 {total_kawaii}")
# 3) 滞在終了判定(最後に動きを検知してから一定時間経ったら終了)
if presence_active and last_motion_time and now - last_motion_time > PRESENCE_TIMEOUT:
duration = now - presence_start
presence_active = False
# 短すぎる滞在は除外
if duration >= MIN_PRESENCE_SEC:
total_presence_sec += duration
print(f"🏡 お世話 {duration:.1f}s / 累計 {total_presence_sec:.1f}s")
# CPU負荷&PIRの揺れ抑制のために少し休む
time.sleep(0.5)
except KeyboardInterrupt:
# Ctrl+C で終了。ここで必ず保存&消灯して終わる(後始末大事)
print("\n🛑 終了")
save_stats(total_kawaii, total_presence_sec)
led.close()
if __name__ == "__main__":
main()
