ゆるエンジニアはいろいろ遊びたい

FAエンジニアが週末にいろいろ遊ぶブログです

Raspberry Pi5でウェイクアップアプリを作る

これまで作ってきたプログラムを総合してウェイクアップアプリを作ってみました。
動作フローは以下の通り

ラズパイ起動時、wakeup_app.py実行
 ↓
BGM再生 (mp3_player.py)
 ↓
効果音再生 (mp3_player.py)
 ↓
今日の日付を取得、geminiに今日は何の日か問い合わせ (gemini.py)
 ↓
今日の日付とgeminiからの返答をAquesTalk Piで音声出力 (aquestalk.py)
 ↓
効果音再生 (mp3_player.py)
 ↓
BGMフェードアウト (mp3_player.py)

pythonファイルは全て同一フォルダ内に入れています。
gemini.pyとmp3_player.pyは過去記事参照
slowtech.hateblo.jp
slowtech.hateblo.jp
gemini.pyについては、history_list = history_list[-100:]の-100の部分を-3にしています。
これは会話履歴が多いとトークンをたくさん消費してしまい、無料枠をすぐに使い切ってしまう事が判明したため、あまり多く保存しないようにしました。
aquestalk.pyも過去記事を参考に一部変更しました。

import subprocess
import shlex

class AquesTalkPi:
    def __init__(self, aquestalk_path="/home/ユーザー名/Documents/aquestalkpi/AquesTalkPi",
                 device="plughw:2,0",
                 sink_name="alsa_output.usb-Jieli_Technology_UACDemoV1.0_4150344C36313516-00.analog-stereo"):
        """
        :param aquestalk_path: AquesTalkPi の実行ファイルのパス
        :param device: aplay の出力先デバイス
        :param sink_name: pactl の sink 名(音量調整用)
        """
        self.aquestalk_path = aquestalk_path
        self.device = device
        self.sink_name = sink_name  # 例: "alsa_output.usb-Speaker-00.analog-stereo"

    def set_volume(self, percent):
        """PipeWire/PulseAudio の音量を変更"""
        if not self.sink_name:
            return

        try:
            subprocess.run(
                ["pactl", "set-sink-volume", self.sink_name, f"{percent}%"],
                check=True
            )
        except Exception as e:
            print("音量調整エラー:", e)

    def speak(self, text, speed=100, voice_type="f1"):
        command = (
            f"echo {shlex.quote(text)} | "
            f"{shlex.quote(self.aquestalk_path)} -s {speed} -v {voice_type} -p -f - | "
            f"paplay --device={self.sink_name}"#pipwire用 2025/12/08更新 f"aplay -D {self.device}"だとmp3_playerと競合する
        )

        try:
            subprocess.run(command, shell=True, check=True)
        except subprocess.CalledProcessError as e:
            print(f"コマンドの実行中にエラーが発生しました: {e}")
        except FileNotFoundError:
            print(f"'{self.aquestalk_path}' または 'aplay' が見つかりません。パスを確認してください。")


# ---- 使用例 ----
#from aquestalk import AquesTalkPi
if __name__ == "__main__":
    aq = AquesTalkPi()

    aq.set_volume(70)  # 音量を70%に設定
    aq.speak("こんにちわ、AquesTalk Piからの音声合成です。", speed=100, voice_type="f2")
    aq.set_volume(50)  # 音量を50%に設定
    aq.speak("CPUの温度は25度です。", speed=100)

ボリュームを調整できるようにしています。
wakeup_app.pyは以下の通り

import datetime
import time
from aquestalk import AquesTalkPi
from gemini import GeminiClient
from mp3_player import MP3Player


def main():
    # ★ AquesTalk 初期化
    aq = AquesTalkPi()
    # ★ MP3 Player 初期化
    bgm = MP3Player()
    sound = MP3Player()
    # ★ 今日の日付を作成
    today = datetime.date.today()
    week_jp = ["月曜日", "火曜日", "水曜日", "木曜日", "金曜日", "土曜日", "日曜日"]
    weekday_text = week_jp[today.weekday()]
    date_text = f"{today.year}年{today.month}月{today.day}日、{weekday_text}です。"

    bgm.set_volume(80)
    bgm.thread_play("/home/ユーザー名/Documents/python_app/bgm/bgm02.mp3", volume=80)
    time.sleep(2)

    # ★ Gemini 初期化と質問作成
    gem = GeminiClient()
    prompt = (
    f"今日は{date_text}です。今日は何の日かを1つだけ、日本語で短く説明してください。\n"
    "・『今日は○○の日です』の後に、詳細な経緯などの説明をつけてください。\n"
    "・推論過程や思考プロセスは書かないでください。\n"
    "・結論のみを自然な話し言葉で答えてください。\n"
    "・記念日が複数ある場合は、まだ回答していないものを優先してください。\n"
    "・「一日」という言葉は「いちにち」と書いてください。\n"
    "・最後に今日の日を絡めて激励の言葉をお願いします。"
    )
    answer = gem.fetch(prompt)
    answer = answer.replace("\n", "").replace("\r", "")  # ← 改行除去

    #起動音
    bgm.set_volume(60)
    sound.set_volume(70)
    sound.play("/home/ユーザー名/Documents/python_app/sounds/sound02.mp3")

    # ★ あいさつ
    aq.set_volume(60)  # 音量を70%に設定
    greeting = f"おはようございます。今日は{date_text}"
    aq.speak(greeting, speed=100, voice_type="f1")
    
    # ★ 音声出力
    aq.speak(answer, speed=100, voice_type="f1")

    #起動音
    sound.set_volume(70)
    sound.play("sounds/sound02.mp3")
    #  BGM 停止
    time.sleep(5)
    for t in range(70, 0, -5):
        bgm.set_volume(t)
        time.sleep(0.5)
    bgm.stop()

    # ★ プログラム終了
    print("Wakeup App 完了。終了します。")

if __name__ == "__main__":
    main()

これをラズパイ起動時に実行できるようにすればOKです。
systemdにwakeup_app.serviceというファイルを作ります。

sudo nano /etc/systemd/system/wakeup_app.service

下記項目を入力します。

[Unit]
Description=Run wakeup.py at startup
After=network-online.target sound.target pipewire.service time-sync.target
Wants=network-online.target time-sync.target
[Service]
Type=simple
User=ユーザー名
Group=audio
WorkingDirectory=/home/ユーザー名/Documents/python_app
Environment="GOOGLE_API_KEY=AIzaSyDVwq0VP-kZ2_Dw0dOiI4Yd7e8ACHffkw8"
Environment="PATH=/usr/bin:/ユーザー名/local/bin:/home/nori/.local/bin"
Environment="XDG_RUNTIME_DIR=/run/user/1000"
ExecStart=/home/ユーザー名/Documents/python_app/.venv/bin/python wakeup_app.py
Restart=No
[Install]
WantedBy=multi-user.target

After=network-online.target sound.target pipewire.service time-sync.target:他のユニットを先に起動してほしい場合に記述。今回はオンライン接続後+pipewire起動後+時刻合わせ後
Wants=network-online.target:弱い依存関係。オンライン接続が必要な場合記述
Environment="XDG_RUNTIME_DIR=/run/user/1000"はPipeWireを使うなら必須
ラズパイはRTCバッテリーを付けないと電源断やシャットダウンをすると時刻がリセットされ、再起動時は前回起動時の日付が設定されます。オンライン復帰時に時刻合わせをしますが、Afterにtime-sync.targetを記述しないと、時刻合わせの前にプログラムが走ってしまいます。
また、デフォルトだとtime-sync.targetは時刻合わせを待ってくれないため、time-wait-syncサービスを有効化する事で日付合わせが終わってからプログラムを走らせることができるみたいです。

sudo systemctl enable systemd-time-wait-sync.service

これで日付が更新された上で動きました。


日付更新による不具合は動作確認に1日経過しないといけないので、原因を解明するまで非常にもどかしい日々を過ごす羽目になりました。
途中であきらめて、常時起動でスケジュールで走らせようかと思いましたが、発生した問題は解決できないとなんか気持ち悪いですからね。
ただ、日付更新はラズパイ起動後1、2分かかるみたいで、なんか微妙な感じでした。
そもそもラズパイは低消費電力をいかした常時起動デバイスとして使い方が合うんでしょうね。
ホームサーバーを作ってみたくなりましたね。