TanaRadio

TanaRadio

Download on the App Store

TanaRadio episodes

  • 385 ポッドキャスト受信機TanaRadio Piを自作すること得たで2つの発見 | テックLTポッドキャストカレンダー

    第6回 テックLTポッドキャストカレンダー参加エピソードです(7月19日分)。

    約1ヶ月前の6月13日に始めたポッドキャスト受信機「TanaRadio Pi」の製作を通して発見した2つのことについて語っています。

    *TanaRadio Piの製作日記をTanaRadioで配信しています。→ #TanaRadioPi

    現在のTanaRadio Pi (愛称: カゴラジオ)

    参考:301 AIによる堅実な開発の20日間 | テックLTポッドキャストカレンダー

    #テックLTポッドキャストカレンダー

    LISTENで開く
    5 min
  • 380 日記 | 電源オンで自動起動

    電源オンで音が出るようにしました。見かけはともかく,機能的にはほぼ期待通りのモノができました(フェーズ5J完了)。

    フェーズ5J 詳細マニュアル
    電源ONでTanaRadio Piを自動起動する
    0. 今回の目標
    今回の完成イメージはこれです。
    Raspberry Piの電源を入れる
    ↓
    自動で tanaradio_5i.py が起動する
    ↓
    現在の棚・再生モードをLEDで表示する
    ↓
    プレイリストを作る
    ↓
    mpvが起動する
    ↓
    USBスピーカーから音が出る
    ↓
    ボタンと音量ダイヤルで操作できる
    今回は、ケースにはまだ入れません。
    まずは、
    モニターなし
    キーボードなし
    マウスなし
    ターミナル操作なし
    で動くかを確認します。
    1. 今回使う方式
    今回は systemd という仕組みを使います。
    これは、Raspberry Piの起動時に自動でプログラムを立ち上げるための標準的な方法です。
    今回作るものは2つです。
    1. 起動用シェルスクリプト
    run_tanaradio_5j.sh
    2. systemdサービス設定
    tanaradio.service
    直接 tanaradio_5i.py を自動起動してもよいのですが、間にシェルスクリプトを置いた方が安全です。
    理由は、起動直後はWi-Fiや音声出力の準備がまだ終わっていないことがあるからです。少し待ってからPythonを起動するようにします。
    2. まず手動実行で現在の状態を確認する
    Raspberry Piでターミナルを開きます。
    cd ~/tanaradio5
    ls
    次のようなファイルがあることを確認します。
    tanaradio_5i.py
    shelf_01_voice_life.txt
    shelf_02_tech_ai_mono.txt
    shelf_03_education_research.txt
    shelf_04_books_culture.txt
    shelf_05_tanaradio.txt
    次に、いつものように手動実行します。
    python3 tanaradio_5i.py
    ここで、これまで通り音が出て、ボタンと音量ダイヤルが効けばOKです。
    終了するときは、
    Ctrl + C
    です。
    ここが成功していない場合は、自動起動に進まないでください。
    自動起動は「動いているものを自動化する」段階です。動いていないものを自動化すると、エラーが忍者のように見えなくなります。
    3. USBスピーカーを確認する
    USBスピーカーが認識されているか確認します。
    aplay -l
    たとえば次のような表示が出れば、USBスピーカーが見えています。
    card 1: Device [USB Audio Device], device 0: USB Audio [USB Audio]
    表示名は機種によって違います。
    USB Audio や Device のような文字があれば、だいたいOKです。
    次にテスト音を鳴らします。
    speaker-test -t wav -c 2
    左右からテスト音が出ればOKです。
    止めるときは、
    Ctrl + C
    です。
    4. mpvだけでUSBスピーカーから音が出るか確認する
    すでに playlist_5i.m3u または playlist_5g.m3u のようなプレイリストがある場合は、それを使って確認します。
    まず、作業フォルダを確認します。
    cd ~/tanaradio5
    ls *.m3u
    表示されたプレイリスト名に合わせて、たとえば次を実行します。
    mpv --no-video playlist_5g.m3u
    または、
    mpv --no-video playlist_5i.m3u
    音が出ればOKです。
    止めるときは、
    q
    です。
    もしプレイリスト名がわからなければ、あとで tanaradio_5i.py が自動で作るので、ここは深追いしなくて大丈夫です。
    5. 起動用シェルスクリプトを作る
    次に、自動起動用のシェルスクリプトを作ります。
    cd ~/tanaradio5
    nano run_tanaradio_5j.sh
    次をそのまま貼り付けます。
    #!/bin/bash
    # TanaRadio Pi フェーズ5J 起動用スクリプト
    cd /home/tana/tanaradio5 || exit 1
    # 起動直後はWi-Fiや音声出力の準備が終わっていないことがあるため、少し待つ
    sleep 12
    # Pythonのprintをログにすぐ出す
    export PYTHONUNBUFFERED=1
    /usr/bin/python3 /home/tana/tanaradio5/tanaradio_5i.py
    保存します。
    Ctrl + O
    Enter
    Ctrl + X
    実行権限を付けます。
    chmod +x run_tanaradio_5j.sh
    6. 起動用スクリプトを手動で試す
    まだ自動起動にはしません。
    先に、このスクリプト自体が動くか確認します。
    ./run_tanaradio_5j.sh
    12秒ほど待ってから、tanaradio_5i.py が起動するはずです。
    音が出て、ボタンとダイヤルが効けばOKです。
    終了するときは、
    Ctrl + C
    です。
    7. systemdサービスファイルを作る
    次に、Raspberry Pi起動時にこのスクリプトを自動実行する設定を作ります。
    sudo nano /etc/systemd/system/tanaradio.service
    次をそのまま貼り付けます。
    [Unit]
    Description=TanaRadio Pi Phase 5J
    Wants=network-online.target
    After=network-online.target sound.target
    [Service]
    Type=simple
    User=tana
    WorkingDirectory=/home/tana/tanaradio5
    ExecStart=/home/tana/tanaradio5/run_tanaradio_5j.sh
    Restart=on-failure
    RestartSec=5
    Environment=PYTHONUNBUFFERED=1
    [Install]
    WantedBy=multi-user.target
    保存します。
    Ctrl + O
    Enter
    Ctrl + X
    ここでは User=tana としています。
    たなさんのRaspberry Piのユーザー名が tana なので、このままでよいはずです。
    8. systemdに設定を読み込ませる
    sudo systemctl daemon-reload
    ここではまだ自動起動は有効化されていません。
    設定ファイルをRaspberry Piに認識させただけです。
    9. まず手動でサービス起動する
    いきなり再起動しないで、まずサービスとして起動します。
    sudo systemctl start tanaradio.service
    少し待ちます。
    12秒後くらいにLEDが点滅し、音が出ればかなり良いです。
    状態を確認します。
    sudo systemctl status tanaradio.service
    画面に次のような表示があればOKです。
    Active: active (running)
    または、すでに何らかの理由で終了していれば、
    Active: failed
    などが出ます。
    終了するときは q です。
    10. ログを見る
    自動起動では、エラーはターミナルに直接出ません。
    その代わり、ログを見ます。
    journalctl -u tanaradio.service -n 80 --no-pager
    リアルタイムで見る場合は、
    journalctl -u tanaradio.service -f
    止めるときは、
    Ctrl + C
    です。
    ログに、
    TanaRadio Pi フェーズ5I
    現在の棚:
    現在の再生モード:
    プレイリストを作り直します
    mpvを起動しました
    のような表示が出ていれば、プログラムは動いています。
    11. サービスを止める方法
    手動で実験したいときは、先にサービスを止めます。
    sudo systemctl stop tanaradio.service
    これは大事です。
    サービスが動いている状態で、さらにターミナルから
    python3 tanaradio_5i.py
    を実行すると、GPIOやmpvのソケットが二重に使われて、おかしな動きになる可能性があります。
    つまり、今後の基本はこうです。
    自動起動で使うとき:
    sudo systemctl start tanaradio.service
    手動で実験するとき:
    sudo systemctl stop tanaradio.service
    python3 tanaradio_5i.py
    12. 自動起動を有効にする
    サービスとして動くことを確認できたら、起動時に自動実行されるようにします。
    sudo systemctl enable tanaradio.service
    これで、次回の電源ONから自動起動します。
    13. 再起動テスト
    いよいよ、モニターなし運用に近いテストです。
    まず、念のため現在のサービスを再起動しておきます。
    sudo systemctl restart tanaradio.service
    問題なく音が出たら、Raspberry Pi全体を再起動します。
    sudo reboot
    再起動後、しばらく待ちます。
    目安としては、
    起動
    ↓
    Wi-Fi準備
    ↓
    12秒待機
    ↓
    Python起動
    ↓
    RSS取得
    ↓
    mpv起動
    ↓
    音が出る
    なので、電源投入から音が出るまで少し時間がかかります。
    ここで焦らないのがコツです。ラジオというより、寝起きの小型サーバーです。
    14. 再起動後の確認
    音が出れば成功です。
    次も確認します。
    再生/一時停止ボタンが効く
    戻るボタンが効く
    次へボタンが効く
    棚ボタンが効く
    モードボタンが効く
    音量ダイヤルが効く
    LEDが点滅する
    これが全部できれば、フェーズ5Jはほぼ成功です。
    15. モニターなしで使う前のSSH確認
    日常運用では、モニターをつながずに使うことになります。
    その場合、問題が起きたときはSSHで入って確認します。
    Macなど別のPCから、Raspberry PiにSSH接続します。
    入れたら、状態確認はこれです。
    sudo systemctl status tanaradio.service
    ログ確認はこれです。
    journalctl -u tanaradio.service -n 80 --no-pager
    再起動はこれです。
    sudo systemctl restart tanaradio.service
    停止はこれです。
    sudo systemctl stop tanaradio.service
    16. 自動起動を一時的に止める方法
    ケース作業や配線変更をするとき、自動でプログラムが起動すると危ない場合があります。
    そのときは、自動起動を無効にします。
    sudo systemctl disable tanaradio.service
    いま動いているサービスも止めるなら、
    sudo systemctl stop tanaradio.service
    もう一度自動起動に戻すときは、
    sudo systemctl enable tanaradio.service
    sudo systemctl start tanaradio.service
    です。
    17. よくあるトラブル
    トラブル1:音が出ない
    まずサービスを止めます。
    sudo systemctl stop tanaradio.service
    手動で音が出るか確認します。
    cd ~/tanaradio5
    python3 tanaradio_5i.py
    手動では音が出るが、自動起動では音が出ない場合は、systemd から起動したmpvが音声出力先をうまくつかめていない可能性があります。
    その場合は、tanaradio_5i.py の mpv 起動部分に、USBスピーカー向けの指定を追加します。
    まず編集します。
    nano ~/tanaradio5/tanaradio_5i.py
    中で、次のような部分を探します。
    cmd = [
    "mpv",
    "--no-video",
    "--really-quiet",
    f"--input-ipc-server={MPV_SOCKET}",
    str(PLAYLIST_FILE),
    ]
    これを次のようにします。
    cmd = [
    "mpv",
    "--no-video",
    "--really-quiet",
    "--ao=alsa",
    f"--input-ipc-server={MPV_SOCKET}",
    str(PLAYLIST_FILE),
    ]
    保存します。
    Ctrl + O
    Enter
    Ctrl + X
    サービスを再起動します。
    sudo systemctl restart tanaradio.service
    これでUSBスピーカーから出る可能性が高くなります。
    トラブル2:起動直後にRSS取得で失敗する
    ログにRSS取得エラーが出る場合、Wi-Fi接続より先にプログラムが動いている可能性があります。
    その場合は、待ち時間を少し伸ばします。
    nano ~/tanaradio5/run_tanaradio_5j.sh
    この行を、
    sleep 12
    たとえば次のようにします。
    sleep 25
    保存して、サービスを再起動します。
    sudo systemctl restart tanaradio.service
    トラブル3:GPIO関係のエラーが出る
    ログにGPIOやpin関係のエラーが出る場合、まず二重起動を疑います。
    ps aux | grep tanaradio
    複数の tanaradio_5i.py が動いていたら、いったん止めます。
    sudo systemctl stop tanaradio.service
    pkill -f tanaradio_5i.py
    pkill -f mpv
    その後、もう一度サービスを起動します。
    sudo systemctl start tanaradio.service
    トラブル4:サービスがすぐ落ちる
    状態を見ます。
    sudo systemctl status tanaradio.service
    詳しいログを見ます。
    journalctl -u tanaradio.service -n 120 --no-pager
    多くの場合、原因は次のどれかです。
    ファイル名が違う
    作業フォルダが違う
    RSS棚ファイルがない
    GPIOピンが別プロセスに使われている
    mpvが見つからない
    音声出力がつかめない
    まず確認するコマンドはこれです。
    ls -l /home/tana/tanaradio5
    which python3
    which mpv
    18. フェーズ5Jの成功条件
    次の5つができれば、フェーズ5J成功です。
    1. sudo systemctl start tanaradio.service で起動する
    2. USBスピーカーから音が出る
    3. 物理ボタンで再生操作できる
    4. 音量ロータリーエンコーダーが効く
    5. sudo reboot 後にも自動で起動して音が出る
    ここまで行けば、TanaRadio Piはもう「毎回ターミナルで実行する実験機」ではありません。
    かなりはっきり言うと、ここを越えたら ラジオです。
    まだ裸のラジオですが、電源を入れたら勝手に声が出る。これはもう道具としての魂が入っています。
    次のフェーズ6Aで、ようやくケース配置に進むのがよいです。

    #声日記 #TanaRadioPi

    LISTENで開く
    29 min
  • 379 日記 | Raspberry Piをケースに入れる

    フェーズ5Jに行こうとしましたが,その前にRaspberry Piをケースに入れるところまでで終わってしまいました。

    *なぜかノイズキャンセリングが効いているようで,とても聴きにくいです。

    #声日記 #TanaRadioPi

    LISTENで開く
    34 min
  • 378 日記 | ロータリーエンコーダーで音量調節

    まず,ロータリーエンコーダーをDIP化基板にハンダ付けして,それからこれを使って音量調整できるようにしました(フェーズ5I完了)。

    フェーズ5I 詳細マニュアル
    ロータリーエンコーダーで音量調整する
    0. 今回の目標
    今回追加する機能はこれです。
    ロータリーエンコーダーを右に回す
    ↓
    音量アップ
    ロータリーエンコーダーを左に回す
    ↓
    音量ダウン
    今回は、エンコーダーの押し込みによるミュートは扱いません。
    たなさんの手元のロータリーエンコーダーは3端子タイプなので、
    A
    C / GND / COM
    B
    の3本だけを使います。
    1. 今回使うGPIO
    フェーズ5Gでは、次の構成で進んでいる前提にします。
    再生/一時停止ボタン:GPIO17
    戻るボタン:GPIO27
    次へボタン:GPIO22
    棚選びボタン:GPIO23
    再生モードボタン:GPIO5
    棚LED:GPIO24
    モードLED:GPIO6
    mpv IPCソケット:
    /tmp/tanaradio-mpv.sock
    今回は、ロータリーエンコーダー用に次の2本を追加します。
    エンコーダーA:GPIO20
    エンコーダーB:GPIO21
    エンコーダーC / COM:GND
    物理ピンで言うと、Raspberry Piの40ピンヘッダーでは次の並びが使いやすいです。
    GPIO20:物理ピン38
    GND :物理ピン39
    GPIO21:物理ピン40
    ここが気持ちよく並んでいます。電子工作の神様が一瞬だけ優しい顔をしています。
    gpiozeroの公式ドキュメントでも、ロータリーエンコーダーはA/BをGPIOへ、CつまりcommonをGNDへ接続する形で説明されています。
    2. 配線する前にRaspberry Piを終了する
    まず、現在動いている tanaradio_5g.py があれば Ctrl + C で止めます。
    そのあと、Raspberry Piを終了します。
    sudo shutdown -h now
    緑LEDの点滅が落ち着き、完全に止まったら電源ケーブルを抜きます。
    GPIO配線は、必ず電源を抜いてから行います。
    特に今回はGPIOが増えるので、ここは慎重でよいです。急がない。ラジオは逃げません。
    3. ロータリーエンコーダーを配線する
    ロータリーエンコーダーの3端子を、次のようにつなぎます。
    ロータリーエンコーダー A → GPIO20
    ロータリーエンコーダー C → GND
    ロータリーエンコーダー B → GPIO21
    DIP化基板に A、C、B のような表記があれば、それに従います。
    もし表記が分かりにくい場合は、中央の端子が C / COM / GND であることが多いです。ただし、ここは部品によって違う可能性もあります。基板に印字があるなら印字を優先してください。
    重要なのはこれです。
    ロータリーエンコーダーには 5V をつながない
    3.3V も今回はつながない
    C / COM は GND につなぐ
    A と B を GPIO20 / GPIO21 につなぐ
    4. 起動して作業フォルダへ移動する
    配線が終わったら、Raspberry Piの電源を入れます。
    起動したらターミナルを開きます。
    cd ~/tanaradio5
    確認します。
    pwd
    次のように表示されればOKです。
    /home/tana/tanaradio5
    5. 必要パッケージを確認する
    念のため、gpiozero関係を入れておきます。
    sudo apt update
    sudo apt install -y python3-gpiozero python3-lgpio
    すでに入っていれば、そのままで大丈夫です。
    6. ロータリーエンコーダー単体テスト
    まず、音量制御とは関係なく、エンコーダーを回したらPythonが反応するかだけ確認します。
    nano test_encoder_5i.py
    次を貼り付けます。
    #!/usr/bin/env python3
    from gpiozero import RotaryEncoder
    from signal import pause
    ENC_A_GPIO = 20
    ENC_B_GPIO = 21
    encoder = RotaryEncoder(
    ENC_A_GPIO,
    ENC_B_GPIO,
    max_steps=0,
    bounce_time=0.002
    )
    def rotated_clockwise():
    print("右回し")
    def rotated_counter_clockwise():
    print("左回し")
    encoder.when_rotated_clockwise = rotated_clockwise
    encoder.when_rotated_counter_clockwise = rotated_counter_clockwise
    print("ロータリーエンコーダーのテストを開始します。")
    print("つまみを左右に回してください。")
    print("終了するには Ctrl+C を押してください。")
    pause()
    保存します。
    Ctrl + O
    Enter
    Ctrl + X
    実行できるようにします。
    chmod +x test_encoder_5i.py
    実行します。
    ./test_encoder_5i.py
    つまみを右に回して、
    右回し
    左に回して、
    左回し
    と出れば成功です。
    終了は、
    Ctrl + C
    です。
    8. 5Gを動かしたまま音量制御テストをする
    次に、tanaradio_5g.py はそのまま使い、別のテストプログラムからmpvへ音量命令を送ります。
    mpvはIPCソケットで外部制御できます。mpvの公式マニュアルでも、外部プログラムから制御する場合は --input-ipc-server を使う方法が説明されています。 また、mpvの音量操作は add volume 5 / add volume -5 のようなコマンドで行えます。
    まず、1つ目のターミナルで5Gを起動します。
    cd ~/tanaradio5
    ./tanaradio_5g.py
    音が出る状態にしておきます。
    次に、別のターミナルを開いて、同じフォルダへ移動します。
    cd ~/tanaradio5
    音量テスト用ファイルを作ります。
    nano test_volume_5i.py
    次を貼り付けます。
    #!/usr/bin/env python3
    import json
    import os
    import socket
    import time
    from gpiozero import RotaryEncoder
    from signal import pause
    MPV_SOCKET = "/tmp/tanaradio-mpv.sock"
    ENC_A_GPIO = 20
    ENC_B_GPIO = 21
    VOLUME_STEP = 5
    MIN_VOLUME_INTERVAL = 0.04
    last_volume_time = 0.0
    encoder = RotaryEncoder(
    ENC_A_GPIO,
    ENC_B_GPIO,
    max_steps=0,
    bounce_time=0.002
    )
    def send_mpv_command(*command):
    if not os.path.exists(MPV_SOCKET):
    print("mpvのソケットが見つかりません。")
    print("先に別ターミナルで ./tanaradio_5g.py を起動してください。")
    return
    try:
    data = json.dumps({"command": list(command)}) + "\n"
    with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
    client.connect(MPV_SOCKET)
    client.sendall(data.encode("utf-8"))
    try:
    response = client.recv(4096)
    if response:
    print(response.decode("utf-8").strip())
    except Exception:
    pass
    except Exception as e:
    print(f"mpvコマンド送信エラー: {e}")
    def change_volume(delta):
    global last_volume_time
    now = time.monotonic()
    if now - last_volume_time < MIN_VOLUME_INTERVAL:
    return
    last_volume_time = now
    if delta > 0:
    print("音量アップ")
    else:
    print("音量ダウン")
    send_mpv_command("add", "volume", delta)
    def volume_up():
    change_volume(VOLUME_STEP)
    def volume_down():
    change_volume(-VOLUME_STEP)
    encoder.when_rotated_clockwise = volume_up
    encoder.when_rotated_counter_clockwise = volume_down
    print("ロータリーエンコーダー音量テストを開始します。")
    print("別ターミナルで tanaradio_5g.py が動いている必要があります。")
    print("つまみを右に回すと音量アップ、左に回すと音量ダウンです。")
    print("終了するには Ctrl+C を押してください。")
    pause()
    保存します。
    Ctrl + O
    Enter
    Ctrl + X
    実行できるようにします。
    chmod +x test_volume_5i.py
    実行します。
    ./test_volume_5i.py
    5Gで音声を流したまま、つまみを回します。
    右回し → 音量アップ
    左回し → 音量ダウン
    になれば、フェーズ5Iの核心部分は成功です。
    9. 5Gコードを5Iとしてコピーする
    ここから本体に統合します。
    まず、5Gコードをコピーします。
    cp tanaradio_5g.py tanaradio_5i.py
    バックアップも作っておきます。
    cp tanaradio_5g.py tanaradio_5g_backup.py
    編集します。
    nano tanaradio_5i.py
    10. RotaryEncoder をimportに追加する
    コードの上の方に、たぶん次のような行があります。
    from gpiozero import Button, LED
    これを次のようにします。
    from gpiozero import Button, LED, RotaryEncoder
    すでに RotaryEncoder が入っていたら、そのままでOKです。
    11. GPIO設定を追加する
    GPIO番号を定義している部分に、次を追加します。
    ENC_A_GPIO = 20
    ENC_B_GPIO = 21
    VOLUME_STEP = 5
    MIN_VOLUME_INTERVAL = 0.04
    last_volume_time = 0.0
    VOLUME_STEP = 5 は、1クリックごとに音量を5ずつ変える設定です。
    大きすぎると感じたら、あとで次のように小さくできます。
    VOLUME_STEP = 3
    または、
    VOLUME_STEP = 2
    12. 音量変更関数を追加する
    ボタンの処理関数が並んでいるあたりに、次を追加します。
    def change_volume(delta):
    global last_volume_time
    now = time.monotonic()
    if now - last_volume_time < MIN_VOLUME_INTERVAL:
    return
    last_volume_time = now
    if delta > 0:
    print(f"音量アップ +{delta}")
    else:
    print(f"音量ダウン {delta}")
    send_mpv_command("add", "volume", delta)
    def volume_up():
    change_volume(VOLUME_STEP)
    def volume_down():
    change_volume(-VOLUME_STEP)
    ここで使っている send_mpv_command() は、5Gですでに使っているmpv制御関数です。
    もし5Gコード内で関数名が違っていて、たとえば、
    send_command(...)
    や、
    mpv_command(...)
    のような名前になっている場合は、この部分だけ5G側の関数名に合わせてください。
    つまり、この行です。
    send_mpv_command("add", "volume", delta)
    ここを、5Gで使っている名前に合わせます。
    13. エンコーダーを作成してイベントを登録する
    ボタンを作っている場所を探します。
    たとえば次のような場所です。
    play_button = Button(...)
    prev_button = Button(...)
    next_button = Button(...)
    shelf_button = Button(...)
    mode_button = Button(...)
    その近くに、次を追加します。
    encoder = RotaryEncoder(
    ENC_A_GPIO,
    ENC_B_GPIO,
    max_steps=0,
    bounce_time=0.002
    )
    encoder.when_rotated_clockwise = volume_up
    encoder.when_rotated_counter_clockwise = volume_down
    もし5Gコードに setup_buttons() のような関数がある場合は、その関数の中に追加してください。
    その場合、最後の return に encoder も含めておくと安全です。
    例:
    def setup_buttons():
    play_button = Button(PLAY_GPIO, pull_up=True, bounce_time=0.05)
    prev_button = Button(PREV_GPIO, pull_up=True, bounce_time=0.05)
    next_button = Button(NEXT_GPIO, pull_up=True, bounce_time=0.05)
    shelf_button = Button(SHELF_GPIO, pull_up=True, bounce_time=0.08)
    mode_button = Button(MODE_GPIO, pull_up=True, bounce_time=0.08)
    encoder = RotaryEncoder(
    ENC_A_GPIO,
    ENC_B_GPIO,
    max_steps=0,
    bounce_time=0.002
    )
    play_button.when_pressed = toggle_pause
    prev_button.when_pressed = previous_episode
    next_button.when_pressed = next_episode
    shelf_button.when_pressed = change_shelf
    mode_button.when_pressed = change_mode
    encoder.when_rotated_clockwise = volume_up
    encoder.when_rotated_counter_clockwise = volume_down
    return [
    play_button,
    prev_button,
    next_button,
    shelf_button,
    mode_button,
    encoder,
    ]
    すでにLEDも同じ関数内で作っている場合は、LEDもreturnに残してください。
    大事なのは、encoder を作ったあと、プログラム終了まで消えないようにすることです。
    Pythonでは、作った部品オブジェクトを変数として保持しておく必要があります。
    14. time がimportされているか確認する
    今回の change_volume() では time.monotonic() を使います。
    コードの上の方に、
    import time
    があるか確認してください。
    なければ追加します。
    import time
    15. 保存して実行する
    保存します。
    Ctrl + O
    Enter
    Ctrl + X
    実行できるようにします。
    chmod +x tanaradio_5i.py
    5Gが動いている場合はいったん止めます。
    Ctrl + C
    5Iを実行します。
    ./tanaradio_5i.py
    確認することは次の通りです。
    再生/一時停止ボタンが効く
    戻るボタンが効く
    次へボタンが効く
    棚選びボタンが効く
    再生モードボタンが効く
    棚LEDが点滅する
    モードLEDが点滅する
    ロータリーエンコーダー右回しで音量アップ
    ロータリーエンコーダー左回しで音量ダウン
    これができれば、フェーズ5I成功です。
    フェーズ5Iの成功条件
    次の3つができれば成功です。
    1. test_encoder_5i.py で右回し/左回しが表示される
    2. test_volume_5i.py で5G再生中の音量を変えられる
    3. tanaradio_5i.py 単体で、5ボタン・2LED・音量つまみが全部動く
    これで、TanaRadio Piはかなり「ラジオ」になります。
    ボタンで番組の棚と時間順を選び、つまみで音量を調整する。ここまで来ると、もう単なるRaspberry Pi工作ではなく、手元の音声装置です。良いところまで来ています。

    #声日記 #TanaRadioPi

    LISTENで開く
    32 min
  • 377 日記 | 再生モードボタンをつける

    再生モード切り替えのためのボタンとLEDを取り付けました(フェーズ5G完了)。

    TanaRadio Pi フェーズ5G 詳細マニュアル
    5つ目のボタンで再生モードを選ぶ
    0. 今回の目標
    フェーズ5Fまでで、すでに次のことができています。
    棚ボタンを押す
    ↓
    棚番号が進む
    ↓
    LED1が棚番号ぶん点滅する
    ↓
    選んだ棚のRSSからプレイリストが作られる
    ↓
    mpvで再生される
    フェーズ5Gでは、ここに「再生モード選び」を追加します。
    再生モードボタンを押す
    ↓
    再生モード番号が進む
    ↓
    LED2がモード番号ぶん点滅する
    ↓
    同じ棚でも、再生対象や並び順が変わる
    ↓
    プレイリストを作り直して再生する
    今回入れる再生モードは、まずこの3つです。
    1:最新順
    2:今日だけ古い順
    3:3日前から古い順
    今回は、まだ次の2つは入れません。
    4:7日前から古い順
    5:未聴っぽいもの
    「未聴っぽいもの」は、ローカル再生履歴が必要です。ここは5H以降に回すのが正解です。焦って入れると、工作からいきなり業務システム開発になります。沼です。まだ長靴はいりません。
    1. 完成後の操作
    フェーズ5G完成後の操作はこうなります。
    再生/一時停止ボタン:
    再生/一時停止
    戻るボタン:
    前のエピソードへ
    次へボタン:
    次のエピソードへ
    棚ボタン:
    棚を1つ進める
    LED1が棚番号ぶん点滅
    再生モードボタン:
    再生モードを1つ進める
    LED2がモード番号ぶん点滅
    棚は、フェーズ5Eで決めた次の構成をそのまま使います。
    1:声日記・生活
    shelf_01_voice_life.txt
    2:技術・AI・ものづくり
    shelf_02_tech_ai_mono.txt
    3:教育・研究・知の実践
    shelf_03_education_research.txt
    4:本・文化・物語
    shelf_04_books_culture.txt
    5:TanaRadio
    shelf_05_tanaradio.txt
    2. 追加する部品
    今回追加するのは、次の2つです。
    タクトスイッチ 1個
    LED 1個
    つまり、5Fまでの構成に加えて、
    再生モード選びボタン
    再生モード表示LED
    を追加します。
    5G終了時点では、こうなります。
    タクトスイッチ:5個
    LED:2個
    ロータリーエンコーダー:まだ使わない
    ロータリーエンコーダーは次の段階です。ここで欲張らないのが、かなり大事です。
    3. GPIO番号
    このマニュアルでは、次のGPIO番号で説明します。
    再生/一時停止ボタン:GPIO17
    戻るボタン:GPIO27
    次へボタン:GPIO22
    棚ボタン:GPIO23
    再生モードボタン:GPIO5
    棚表示LED:GPIO24
    再生モード表示LED:GPIO6
    すでに5Fで別のGPIO番号を使っている場合は、あとで出てくるPythonコードのこの部分だけ変更してください。
    PIN_PLAY = 17
    PIN_PREV = 27
    PIN_NEXT = 22
    PIN_SHELF = 23
    PIN_MODE = 5
    PIN_LED_SHELF = 24
    PIN_LED_MODE = 6
    4. 配線
    4.1 再生モードボタンの配線
    新しく追加するタクトスイッチを、次のようにつなぎます。
    GPIO5 ─ 1kΩ程度の抵抗 ─ タクトスイッチ ─ GND
    これまでのボタンと同じ考え方です。
    内部プルアップを使うので、ボタンを押していないときはHIGH、押したときにGNDへ落ちてLOWになります。
    4.2 再生モード表示LEDの配線
    新しく追加するLEDを、次のようにつなぎます。
    GPIO6 ─ 330Ω抵抗 ─ LEDの長い足
    LEDの短い足 ─ GND
    LEDには向きがあります。
    長い足:GPIO側
    短い足:GND側
    LEDが光らない場合、まず疑うべきはLEDの向きです。コードより先に足を疑ってください。電子工作では、だいたい足か線です。
    5. いったん電源を切って配線する
    配線を追加する前に、Raspberry Piを終了します。
    sudo shutdown -h now
    電源が落ちたら、電源ケーブルを抜いてから配線します。
    配線が終わったら、もう一度電源を入れます。
    6. 作業フォルダへ移動する
    Raspberry Piが起動したら、ターミナルを開きます。
    cd ~/tanaradio5
    現在地を確認します。
    pwd
    次のようになればOKです。
    /home/tana/tanaradio5
    7. 5Fのコードをバックアップする
    フェーズ5Fがうまく動いているので、必ずバックアップします。
    もし現在のファイル名が tanaradio_5f.py なら、次のようにします。
    cp tanaradio_5f.py tanaradio_5f_ok.py
    もしファイル名が違う場合は、その名前に合わせてください。
    例:
    cp 今動いているファイル名.py 5f_ok.py
    これは保険です。
    5Gで何か変になっても、5Fに戻れます。工作でもプログラムでも、戻れる場所を作る人は強いです。
    8. 必要なパッケージを確認する
    すでに入っているはずですが、念のため確認します。
    python3 -c "import gpiozero; print('gpiozero OK')"
    gpiozero OK と出ればOKです。
    もしエラーが出たら、次を実行します。
    sudo apt update
    sudo apt install -y python3-gpiozero python3-lgpio
    mpvも確認します。
    mpv --version
    バージョン情報が出ればOKです。
    9. 新しい5Gコードを作る
    新しく tanaradio_5g.py を作ります。
    nano tanaradio_5g.py
    次のコードをそのまま貼り付けます。
    #!/usr/bin/env python3
    import json
    import socket
    import subprocess
    import time
    import urllib.request
    import xml.etree.ElementTree as ET
    from dataclasses import dataclass
    from datetime import datetime, timedelta, timezone
    from email.utils import parsedate_to_datetime
    from pathlib import Path
    from signal import pause
    from zoneinfo import ZoneInfo
    from gpiozero import Button, LED
    BASE_DIR = Path(__file__).resolve().parent
    PLAYLIST_FILE = BASE_DIR / "playlist_5g.m3u"
    STATE_FILE = BASE_DIR / "state_5g.json"
    MPV_SOCKET = "/tmp/tanaradio-mpv.sock"
    TZ = ZoneInfo("Asia/Tokyo")
    MAX_ITEMS = 80
    # ===== GPIO番号(BCM) =====
    # 5Fまでの配線と違う場合は、ここだけ自分の配線に合わせて変更してください。
    PIN_PLAY = 17
    PIN_PREV = 27
    PIN_NEXT = 22
    PIN_SHELF = 23
    PIN_MODE = 5
    PIN_LED_SHELF = 24
    PIN_LED_MODE = 6
    SHELVES = [
    {
    "key": "voice",
    "name": "声日記・生活",
    "file": "shelf_01_voice_life.txt",
    },
    {
    "key": "tech",
    "name": "技術・AI・ものづくり",
    "file": "shelf_02_tech_ai_mono.txt",
    },
    {
    "key": "edu",
    "name": "教育・研究・知の実践",
    "file": "shelf_03_education_research.txt",
    },
    {
    "key": "books",
    "name": "本・文化・物語",
    "file": "shelf_04_books_culture.txt",
    },
    {
    "key": "tana",
    "name": "TanaRadio",
    "file": "shelf_05_tanaradio.txt",
    },
    ]
    # 5Gではまず3種類だけにします。
    # 「7日前から古い順」「未聴っぽいもの」は、次の段階で足します。
    MODES = [
    {
    "key": "latest",
    "name": "最新順",
    },
    {
    "key": "today_oldest",
    "name": "今日だけ古い順",
    },
    {
    "key": "last3_oldest",
    "name": "3日前から古い順",
    },
    ]
    @dataclass
    class Episode:
    title: str
    program: str
    pub_dt: datetime
    audio_url: str
    mpv_process = None
    busy = False
    def clamp_index(value, size, default=0):
    if not isinstance(value, int):
    return default
    if value < 0 or value >= size:
    return default
    return value
    def load_state():
    default_state = {
    "shelf": 0,
    "mode": 2, # 初期値は「3日前から古い順」
    }
    if not STATE_FILE.exists():
    return default_state
    try:
    data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
    return {
    "shelf": clamp_index(data.get("shelf"), len(SHELVES), 0),
    "mode": clamp_index(data.get("mode"), len(MODES), 2),
    }
    except Exception:
    return default_state
    def save_state(state):
    STATE_FILE.write_text(
    json.dumps(state, ensure_ascii=False, indent=2) + "\n",
    encoding="utf-8",
    )
    def read_feed_urls(shelf_file):
    path = BASE_DIR / shelf_file
    if not path.exists():
    raise FileNotFoundError(f"{shelf_file} が見つかりません。")
    urls = []
    for line in path.read_text(encoding="utf-8").splitlines():
    line = line.strip()
    if not line:
    continue
    if line.startswith("#"):
    continue
    urls.append(line)
    if not urls:
    raise ValueError(f"{shelf_file} にRSS URLがありません。")
    return urls
    def download_rss(url):
    request = urllib.request.Request(
    url,
    headers={
    "User-Agent": "TanaRadio-Pi/5G",
    },
    )
    with urllib.request.urlopen(request, timeout=25) as response:
    return response.read()
    def parse_pubdate(text):
    if not text:
    return datetime(1970, 1, 1, tzinfo=timezone.utc).astimezone(TZ)
    try:
    dt = parsedate_to_datetime(text)
    except Exception:
    try:
    dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
    except Exception:
    dt = datetime(1970, 1, 1, tzinfo=timezone.utc)
    if dt.tzinfo is None:
    dt = dt.replace(tzinfo=timezone.utc)
    return dt.astimezone(TZ)
    def find_audio_url(item):
    enclosure = item.find("enclosure")
    if enclosure is not None:
    audio_url = enclosure.attrib.get("url")
    if audio_url:
    return audio_url
    for child in item:
    if child.tag.endswith("content"):
    audio_url = child.attrib.get("url")
    if audio_url:
    return audio_url
    return ""
    def find_text(item, tag_name):
    element = item.find(tag_name)
    if element is not None and element.text:
    return element.text.strip()
    return ""
    def extract_episodes_from_rss(rss_data):
    root = ET.fromstring(rss_data)
    channel = root.find("channel")
    if channel is None:
    raise ValueError("RSSのchannelが見つかりません。")
    program = channel.findtext("title", default="").strip()
    episodes = []
    for item in channel.findall("item"):
    audio_url = find_audio_url(item)
    if not audio_url:
    continue
    title = find_text(item, "title") or "(no title)"
    pub_text = find_text(item, "pubDate")
    pub_dt = parse_pubdate(pub_text)
    episodes.append(
    Episode(
    title=title,
    program=program,
    pub_dt=pub_dt,
    audio_url=audio_url,
    )
    )
    return episodes
    def collect_episodes(shelf):
    urls = read_feed_urls(shelf["file"])
    all_episodes = []
    print(f"棚: {shelf['name']}")
    print(f"RSS数: {len(urls)}")
    for url in urls:
    try:
    print(f"RSS取得: {url}")
    rss_data = download_rss(url)
    episodes = extract_episodes_from_rss(rss_data)
    all_episodes.extend(episodes)
    print(f" 取得エピソード数: {len(episodes)}")
    except Exception as e:
    print(f" このRSSは読み飛ばします: {e}")
    return all_episodes
    def select_episodes_by_mode(episodes, mode):
    now = datetime.now(TZ)
    if mode["key"] == "latest":
    selected = sorted(episodes, key=lambda e: e.pub_dt, reverse=True)
    elif mode["key"] == "today_oldest":
    today = now.date()
    selected = [
    e for e in episodes
    if e.pub_dt.date() == today
    ]
    selected = sorted(selected, key=lambda e: e.pub_dt)
    elif mode["key"] == "last3_oldest":
    since = now - timedelta(days=3)
    selected = [
    e for e in episodes
    if e.pub_dt >= since
    ]
    selected = sorted(selected, key=lambda e: e.pub_dt)
    else:
    selected = sorted(episodes, key=lambda e: e.pub_dt, reverse=True)
    return selected[:MAX_ITEMS]
    def write_playlist(episodes):
    lines = ["#EXTM3U"]
    for episode in episodes:
    title = f"{episode.program} - {episode.title}".replace("\n", " ")
    pub = episode.pub_dt.strftime("%Y-%m-%d %H:%M")
    lines.append(f"# {pub} {title}")
    lines.append(f"#EXTINF:-1,{title}")
    lines.append(episode.audio_url)
    PLAYLIST_FILE.write_text(
    "\n".join(lines) + "\n",
    encoding="utf-8",
    )
    print(f"{PLAYLIST_FILE.name} を作成しました。")
    print(f"再生対象: {len(episodes)} 件")
    def playlist_has_audio():
    if not PLAYLIST_FILE.exists():
    return False
    for line in PLAYLIST_FILE.read_text(encoding="utf-8").splitlines():
    line = line.strip()
    if line and not line.startswith("#"):
    return True
    return False
    def stop_mpv():
    global mpv_process
    if mpv_process is not None and mpv_process.poll() is None:
    mpv_process.terminate()
    try:
    mpv_process.wait(timeout=3)
    except subprocess.TimeoutExpired:
    mpv_process.kill()
    mpv_process = None
    def start_mpv():
    global mpv_process
    stop_mpv()
    if not playlist_has_audio():
    print("再生できる音声URLがないため、mpvは起動しません。")
    print("今日モードで0件の場合などは正常です。")
    return
    socket_path = Path(MPV_SOCKET)
    if socket_path.exists():
    try:
    socket_path.unlink()
    except Exception:
    pass
    cmd = [
    "mpv",
    "--no-video",
    "--really-quiet",
    f"--input-ipc-server={MPV_SOCKET}",
    str(PLAYLIST_FILE),
    ]
    mpv_process = subprocess.Popen(cmd)
    time.sleep(0.5)
    print("mpvを起動しました。")
    def send_mpv_command(command):
    try:
    with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
    client.connect(MPV_SOCKET)
    message = json.dumps({"command": command}) + "\n"
    client.send(message.encode("utf-8"))
    except Exception as e:
    print(f"mpvへ命令を送れませんでした: {e}")
    def blink(led, count):
    count = max(1, int(count))
    for _ in range(count):
    led.on()
    time.sleep(0.18)
    led.off()
    time.sleep(0.18)
    def rebuild_playlist_and_restart():
    state = load_state()
    shelf = SHELVES[state["shelf"]]
    mode = MODES[state["mode"]]
    print("")
    print("=== プレイリストを作り直します ===")
    print(f"棚: {state['shelf'] + 1}. {shelf['name']}")
    print(f"再生モード: {state['mode'] + 1}. {mode['name']}")
    episodes = collect_episodes(shelf)
    selected = select_episodes_by_mode(episodes, mode)
    write_playlist(selected)
    start_mpv()
    def run_exclusive(action):
    global busy
    if busy:
    print("処理中です。少し待ってください。")
    return
    busy = True
    try:
    action()
    except Exception as e:
    print(f"エラー: {e}")
    finally:
    busy = False
    def on_play():
    print("再生/一時停止")
    send_mpv_command(["cycle", "pause"])
    def on_prev():
    print("前のエピソードへ")
    send_mpv_command(["playlist-prev"])
    def on_next():
    print("次のエピソードへ")
    send_mpv_command(["playlist-next"])
    def on_shelf():
    def action():
    state = load_state()
    state["shelf"] = (state["shelf"] + 1) % len(SHELVES)
    save_state(state)
    shelf = SHELVES[state["shelf"]]
    print(f"棚を変更: {state['shelf'] + 1}. {shelf['name']}")
    blink(led_shelf, state["shelf"] + 1)
    rebuild_playlist_and_restart()
    run_exclusive(action)
    def on_mode():
    def action():
    state = load_state()
    state["mode"] = (state["mode"] + 1) % len(MODES)
    save_state(state)
    mode = MODES[state["mode"]]
    print(f"再生モードを変更: {state['mode'] + 1}. {mode['name']}")
    blink(led_mode, state["mode"] + 1)
    rebuild_playlist_and_restart()
    run_exclusive(action)
    def print_status():
    state = load_state()
    shelf = SHELVES[state["shelf"]]
    mode = MODES[state["mode"]]
    print("")
    print("TanaRadio Pi フェーズ5G")
    print("========================")
    print(f"現在の棚: {state['shelf'] + 1}. {shelf['name']}")
    print(f"現在の再生モード: {state['mode'] + 1}. {mode['name']}")
    print("")
    print("操作:")
    print(" 再生ボタン: 再生/一時停止")
    print(" 戻るボタン: 前のエピソード")
    print(" 次へボタン: 次のエピソード")
    print(" 棚ボタン: 棚を進める。LED1が棚番号ぶん点滅")
    print(" モードボタン: 再生モードを進める。LED2がモード番号ぶん点滅")
    print(" Ctrl + C: 終了")
    print("")
    # ===== GPIOオブジェクト =====
    btn_play = Button(PIN_PLAY, pull_up=True, bounce_time=0.05)
    btn_prev = Button(PIN_PREV, pull_up=True, bounce_time=0.05)
    btn_next = Button(PIN_NEXT, pull_up=True, bounce_time=0.05)
    btn_shelf = Button(PIN_SHELF, pull_up=True, bounce_time=0.05)
    btn_mode = Button(PIN_MODE, pull_up=True, bounce_time=0.05)
    led_shelf = LED(PIN_LED_SHELF)
    led_mode = LED(PIN_LED_MODE)
    def main():
    btn_play.when_pressed = on_play
    btn_prev.when_pressed = on_prev
    btn_next.when_pressed = on_next
    btn_shelf.when_pressed = on_shelf
    btn_mode.when_pressed = on_mode
    print_status()
    state = load_state()
    blink(led_shelf, state["shelf"] + 1)
    time.sleep(0.4)
    blink(led_mode, state["mode"] + 1)
    run_exclusive(rebuild_playlist_and_restart)
    try:
    pause()
    except KeyboardInterrupt:
    print("\n終了します。")
    finally:
    stop_mpv()
    led_shelf.off()
    led_mode.off()
    if __name__ == "__main__":
    main()
    保存します。
    Ctrl + O
    Enter
    Ctrl + X
    10. 実行権限をつける
    chmod +x tanaradio_5g.py
    11. 棚ファイルがあるか確認する
    次を実行します。
    ls shelf_*.txt
    次の5つが表示されればOKです。
    shelf_01_voice_life.txt
    shelf_02_tech_ai_mono.txt
    shelf_03_education_research.txt
    shelf_04_books_culture.txt
    shelf_05_tanaradio.txt
    中身も軽く確認します。
    head shelf_01_voice_life.txt
    RSS URLが入っていればOKです。
    12. 5Gを実行する
    まず、既存のmpvが残っている場合は止めます。
    pkill mpv
    そして実行します。
    ./tanaradio_5g.py
    うまくいくと、ターミナルに次のような表示が出ます。
    TanaRadio Pi フェーズ5G
    ========================
    現在の棚: 1. 声日記・生活
    現在の再生モード: 3. 3日前から古い順
    操作:
    再生ボタン: 再生/一時停止
    戻るボタン: 前のエピソード
    次へボタン: 次のエピソード
    棚ボタン: 棚を進める。LED1が棚番号ぶん点滅
    モードボタン: 再生モードを進める。LED2がモード番号ぶん点滅
    Ctrl + C: 終了
    その後、RSSを取得し、プレイリストが作られ、mpvが起動します。
    === プレイリストを作り直します ===
    棚: 1. 声日記・生活
    再生モード: 3. 3日前から古い順
    RSS数: ...
    playlist_5g.m3u を作成しました。
    再生対象: ... 件
    mpvを起動しました。
    音が出れば、まず成功です。
    13. 動作テスト
    13.1 起動時のLED点滅
    起動直後に、
    LED1:棚番号ぶん点滅
    LED2:再生モード番号ぶん点滅
    します。
    初期状態では、再生モードは3番にしてあります。
    LED2が3回点滅
    → 3日前から古い順
    13.2 再生モードボタンを押す
    再生モードボタンを押します。
    1回押すごとに、次のように進みます。
    1:最新順
    ↓
    2:今日だけ古い順
    ↓
    3:3日前から古い順
    ↓
    1:最新順
    ボタンを押すと、LED2がモード番号ぶん点滅します。
    LED2が1回点滅
    → 最新順
    LED2が2回点滅
    → 今日だけ古い順
    LED2が3回点滅
    → 3日前から古い順
    その後、プレイリストが作り直され、mpvが再起動します。
    13.3 棚ボタンも確認する
    棚ボタンも、5Fと同じように動くか確認します。
    棚ボタンを押す
    ↓
    LED1が棚番号ぶん点滅
    ↓
    選んだ棚でプレイリスト作成
    ↓
    再生
    ここが動けば、5Fの機能は壊れていません。
    14. 再生モードの意味
    1:最新順
    RSSから取れたエピソードを、新しいものから順に並べます。
    新しい
    ↓
    古い
    新着チェック向きです。
    2:今日だけ古い順
    今日公開されたエピソードだけを対象にします。
    今日の古いもの
    ↓
    今日の新しいもの
    今日のエピソードがない場合は、
    再生対象: 0 件
    再生できる音声URLがないため、mpvは起動しません。
    今日モードで0件の場合などは正常です。
    と表示されます。
    これはエラーではありません。
    単に「今日はまだ該当エピソードがない」という意味です。
    3:3日前から古い順
    現在時刻から見て、過去3日以内のエピソードを対象にします。
    3日前
    ↓
    2日前
    ↓
    昨日
    ↓
    今日
    これは「追いつき再生」向きです。
    TanaRadio Piの日常利用では、このモードがいちばんラジオらしいと思います。
    15. 作られるファイル
    5Gでは、新しく次のファイルが作られます。
    playlist_5g.m3u
    state_5g.json
    現在の棚と再生モードに応じて作られるmpv用プレイリストです。
    中身を確認するには、
    head playlist_5g.m3u
    state_5g.json
    現在選ばれている棚番号と再生モード番号を保存するファイルです。
    確認するには、
    cat state_5g.json
    例:
    {
    "shelf": 0,
    "mode": 2
    }
    Python内部では0から数えるため、
    "shelf": 0 → 棚1
    "mode": 2 → モード3
    という意味になります。
    ここは少しややこしいですが、正常です。コンピュータは0から数えたがる生き物です。
    16. 終了方法
    プログラムを止めるには、ターミナルで次を押します。
    Ctrl + C
    すると、
    終了します。
    と出て、mpvも止まります。
    もし音だけ残ってしまった場合は、次で止めます。
    pkill mpv
    18. 成功条件
    フェーズ5Gの成功条件は、次の5つです。
    1. 5つ目のボタンを押すと、再生モードが切り替わる
    2. LED2が再生モード番号ぶん点滅する
    3. 最新順・今日だけ古い順・3日前から古い順が切り替わる
    4. 棚ボタンとLED1もこれまで通り動く
    5. 再生/一時停止・戻る・次へボタンも動く
    これができれば、フェーズ5Gは成功です。
    19. 今回はここで止めてよいです
    5Gまで来ると、TanaRadio Piはかなり「声の本棚」らしくなっています。
    どの棚を聴くか
    どの時間順で聴くか
    この2つを、物理ボタンで選べるようになったからです。
    次の段階は、おそらく次のどちらかです。
    フェーズ5H:
    ローカル再生履歴を作る
    フェーズ5I:
    ロータリーエンコーダーで音量調整する
    私のおすすめは、まだ再生履歴には行かず、先にロータリーエンコーダーを音量用として追加することです。理由は単純で、音量ダイヤルが付くと一気にラジオ感が出るからです。やる気が出る工作は、正義です。

    #声日記 #TanaRadioPi

    LISTENで開く
    18 min
  • 376 日記 | 棚選択ボタンとLEDランプを追加

    ボタンで棚を選択できるようにしました。選んでいる棚番号はLEDランプの点滅回数で表示します(フェーズ5F完了)。

    # TanaRadio Pi フェーズ5F 詳細マニュアル
    ## 棚選びボタンとLED1を追加する
    ## 0. 今回の目標
    フェーズ5Eでは、棚ファイルを指定してプレイリストを作り、mpvで再生できるようになりました。
    フェーズ5Fでは、その「棚の切り替え」を物理ボタンで行えるようにします。
    今回の動きは次の通りです。
    ```text
    棚選びボタンを押す
    ↓
    棚番号が1つ進む
    ↓
    LED1が棚番号ぶん点滅する
    ↓
    選ばれた棚ファイルから playlist.m3u を作る
    ↓
    mpvをその棚で再生し直す
    ```
    今回はまだ、再生モード選びボタンは追加しません。
    再生モードは、5Eまでと同じく、
    ```text
    過去3日分
    古い順
    ```
    を基本にします。
    ---
    # 1. 今回使う棚構成
    フェーズ5Fでは、フェーズ5Eで作った次の棚構成を使います。
    ```text
    1: 声日記・生活 (voice) -> shelf_01_voice_life.txt
    2: 技術・AI・ものづくり (tech) -> shelf_02_tech_ai_mono.txt
    3: 教育・研究・知の実践 (edu) -> shelf_03_education_research.txt
    4: 本・文化・物語 (books) -> shelf_04_books_culture.txt
    5: TanaRadio (tana) -> shelf_05_tanaradio.txt
    ```
    棚選びボタンを押すと、この順番で進みます。
    ```text
    声日記・生活
    ↓
    技術・AI・ものづくり
    ↓
    教育・研究・知の実践
    ↓
    本・文化・物語
    ↓
    TanaRadio
    ↓
    声日記・生活
    ```
    LED1の点滅回数は、次の対応です。
    ```text
    1回点滅:声日記・生活
    2回点滅:技術・AI・ものづくり
    3回点滅:教育・研究・知の実践
    4回点滅:本・文化・物語
    5回点滅:TanaRadio
    ```
    ---
    # 2. 今回追加する部品
    今回追加する部品は2つです。
    ```text
    タクトスイッチ 1個
    LED 1個
    ```
    用途は次の通りです。
    ```text
    タクトスイッチ:棚選びボタン
    LED:棚番号表示LED
    ```
    既存の3ボタンは、そのまま使います。
    ```text
    再生/一時停止
    戻る
    次へ
    ```
    つまり、フェーズ5F終了時点では、次の構成になります。
    ```text
    ボタン1:再生/一時停止
    ボタン2:戻る
    ボタン3:次へ
    ボタン4:棚選び
    LED1:棚番号表示
    ```
    ---
    # 3. GPIO番号
    このマニュアルでは、次のGPIO番号を使います。
    ```text
    再生/一時停止:GPIO17
    戻る:GPIO27
    次へ:GPIO22
    棚選びボタン:GPIO23
    棚番号LED:GPIO24
    ```
    Raspberry Piの物理ピン番号では、次の対応です。
    ```text
    GPIO17:物理ピン11
    GPIO27:物理ピン13
    GPIO22:物理ピン15
    GPIO23:物理ピン16
    GPIO24:物理ピン18
    GND:物理ピン6, 9, 14, 20, 25 など
    ```
    注意点です。
    ```text
    GPIO番号と物理ピン番号は違います。
    ```
    コードでは「GPIO番号」を使います。
    たとえば、
    ```text
    GPIO23
    ```
    は、物理ピン番号では、
    ```text
    16番ピン
    ```
    です。
    ここを取り違えると、ボタンを押しても無反応になります。静かな失敗です。電子工作ではいちばん地味に困るやつです。
    ---
    # 4. 電源を切る
    配線を触る前に、必ずRaspberry Piを終了します。
    ターミナルで次を実行します。
    ```bash
    sudo shutdown -h now
    ```
    完全に電源が切れてから、USB-C電源を抜きます。
    ---
    # 5. 棚選びボタンを配線する
    棚選びボタンは、次のようにつなぎます。
    ```text
    GPIO23 ─ 1kΩ抵抗 ─ タクトスイッチ ─ GND
    ```
    この構成では、Python側で内部プルアップを使います。
    そのため、動作は次のようになります。
    ```text
    ボタンを押していない:HIGH
    ボタンを押した:LOW
    ```
    これまでのボタンと同じ考え方です。
    ---
    # 6. 棚番号LEDを配線する
    LEDは、次のようにつなぎます。
    ```text
    GPIO24 ─ 330Ω抵抗 ─ LEDの長い足
    LEDの短い足 ─ GND
    ```
    LEDの足の目安です。
    ```text
    長い足:プラス側
    短い足:GND側
    ```
    光らない場合は、まずLEDの向きを疑ってください。
    LEDは、向きが逆だと基本的に光りません。
    壊れたわけではなく、ただ黙っているだけです。
    ---
    # 7. Raspberry Piを起動する
    配線が終わったら、Raspberry Piの電源を入れます。
    ターミナルを開き、作業フォルダに移動します。
    ```bash
    cd ~/tanaradio5
    ```
    現在地を確認します。
    ```bash
    pwd
    ```
    次のように表示されればOKです。
    ```text
    /home/tana/tanaradio5
    ```
    ユーザー名が違う場合は、そこだけ変わります。
    ---
    # 8. 必要なパッケージを確認する
    GPIOとmpvを使うため、念のため次を実行します。
    ```bash
    sudo apt update
    sudo apt install -y python3-gpiozero python3-lgpio mpv
    ```
    すでに入っている場合は、そのままで大丈夫です。
    ---
    # 9. 棚ファイルを確認する
    今回使う棚ファイルがあるか確認します。
    ```bash
    ls shelf_*.txt
    ```
    次の5つが見えればOKです。
    ```text
    shelf_01_voice_life.txt
    shelf_02_tech_ai_mono.txt
    shelf_03_education_research.txt
    shelf_04_books_culture.txt
    shelf_05_tanaradio.txt
    ```
    それぞれの中身も確認します。
    ```bash
    cat shelf_01_voice_life.txt
    cat shelf_02_tech_ai_mono.txt
    cat shelf_03_education_research.txt
    cat shelf_04_books_culture.txt
    cat shelf_05_tanaradio.txt
    ```
    RSS URLが1行ずつ並んでいればOKです。
    例です。
    ```text
    https://listen.style/p/xxxx/rss
    https://listen.style/p/yyyy/rss
    https://listen.style/p/zzzz/rss
    ```
    空の棚ファイルがある場合、その棚に切り替えたときにエラーになります。
    テスト段階では、空の棚にも一時的に動作確認用のRSS URLを1本入れておくと安心です。
    ---
    # 10. 5F用Pythonファイルを作る
    新しいPythonファイルを作ります。
    ```bash
    nano tanaradio_5f.py
    ```
    次のコードをそのまま貼り付けます。
    重要です。
    最初の行は必ず、次で始めてください。
    ```text
    #!/usr/bin/env python3
    ```
    この前に、余計な文字を入れないでください。
    ```python
    #!/usr/bin/env python3
    import argparse
    import json
    import os
    import signal
    import socket
    import subprocess
    import time
    import urllib.request
    import xml.etree.ElementTree as ET
    from dataclasses import dataclass
    from datetime import datetime, timedelta, timezone
    from email.utils import parsedate_to_datetime
    from pathlib import Path
    from threading import Lock
    from gpiozero import Button, LED
    # =========================
    # GPIO設定
    # 5Dまでと違うGPIOを使っていた場合は、ここだけ直してください。
    # BCM番号です。物理ピン番号ではありません。
    # =========================
    PLAY_GPIO = 17 # 再生/一時停止ボタン
    PREV_GPIO = 27 # 戻るボタン
    NEXT_GPIO = 22 # 次へボタン
    SHELF_GPIO = 23 # 棚選びボタン(今回追加)
    SHELF_LED_GPIO = 24 # 棚番号表示LED(今回追加)
    # =========================
    # ファイル設定
    # =========================
    BASE_DIR = Path(__file__).resolve().parent
    PLAYLIST_FILE = BASE_DIR / "playlist.m3u"
    STATE_FILE = BASE_DIR / "state_5f.json"
    MPV_SOCKET = "/tmp/tanaradio_5f_mpv.sock"
    # フェーズ5Fでは、まだ再生モードボタンは追加しません。
    # 5Eまでと同じく「過去3日分・古い順」を基本にします。
    DAYS_BACK = 3
    # =========================
    # 棚設定
    # フェーズ5Eで作った棚構成に合わせています。
    # =========================
    SHELVES = [
    {
    "label": "声日記・生活",
    "short": "voice",
    "file": "shelf_01_voice_life.txt",
    },
    {
    "label": "技術・AI・ものづくり",
    "short": "tech",
    "file": "shelf_02_tech_ai_mono.txt",
    },
    {
    "label": "教育・研究・知の実践",
    "short": "edu",
    "file": "shelf_03_education_research.txt",
    },
    {
    "label": "本・文化・物語",
    "short": "books",
    "file": "shelf_04_books_culture.txt",
    },
    {
    "label": "TanaRadio",
    "short": "tana",
    "file": "shelf_05_tanaradio.txt",
    },
    ]
    @dataclass
    class Episode:
    title: str
    audio_url: str
    pub_date: datetime | None
    feed_title: str
    mpv_process: subprocess.Popen | None = None
    current_shelf_index = 0
    shelf_lock = Lock()
    shelf_led: LED | None = None
    def read_lines(path: Path) -> list[str]:
    if not path.exists():
    return []
    lines = []
    for raw_line in path.read_text(encoding="utf-8").splitlines():
    line = raw_line.strip()
    if not line:
    continue
    if line.startswith("#"):
    continue
    lines.append(line)
    return lines
    def load_state() -> int:
    if not STATE_FILE.exists():
    return 0
    try:
    data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
    index = int(data.get("shelf_index", 0))
    if 0 <= index < len(SHELVES):
    return index
    except Exception:
    pass
    return 0
    def save_state(index: int) -> None:
    data = {
    "shelf_index": index,
    "shelf_label": SHELVES[index]["label"],
    "shelf_short": SHELVES[index]["short"],
    "shelf_file": SHELVES[index]["file"],
    "saved_at": datetime.now().isoformat(timespec="seconds"),
    }
    STATE_FILE.write_text(
    json.dumps(data, ensure_ascii=False, indent=2),
    encoding="utf-8",
    )
    def download_rss(url: str) -> bytes:
    request = urllib.request.Request(
    url,
    headers={"User-Agent": "TanaRadio-Pi/5F"},
    )
    with urllib.request.urlopen(request, timeout=25) as response:
    return response.read()
    def get_child_text(parent: ET.Element, tag_name: str) -> str:
    element = parent.find(tag_name)
    if element is not None and element.text:
    return element.text.strip()
    return ""
    def find_audio_url(item: ET.Element) -> str | None:
    # 一般的なRSS Podcastのenclosureタグ
    enclosure = item.find("enclosure")
    if enclosure is not None:
    audio_url = enclosure.attrib.get("url")
    if audio_url:
    return audio_url
    # media:content など、名前空間つきタグにも軽く対応
    for child in item:
    if child.tag.endswith("content"):
    audio_url = child.attrib.get("url")
    if audio_url:
    return audio_url
    return None
    def parse_pub_date(text: str) -> datetime | None:
    if not text:
    return None
    try:
    dt = parsedate_to_datetime(text)
    if dt.tzinfo is None:
    dt = dt.replace(tzinfo=timezone.utc)
    return dt.astimezone()
    except Exception:
    return None
    def parse_rss(rss_data: bytes, feed_url: str) -> list[Episode]:
    root = ET.fromstring(rss_data)
    channel = root.find("channel")
    if channel is None:
    raise ValueError("RSSのchannelが見つかりません。")
    feed_title = get_child_text(channel, "title") or feed_url
    episodes: list[Episode] = []
    for item in channel.findall("item"):
    audio_url = find_audio_url(item)
    if not audio_url:
    continue
    title = get_child_text(item, "title") or "タイトルなし"
    pub_date = parse_pub_date(get_child_text(item, "pubDate"))
    episodes.append(
    Episode(
    title=title,
    audio_url=audio_url,
    pub_date=pub_date,
    feed_title=feed_title,
    )
    )
    return episodes
    def collect_episodes_for_shelf(index: int) -> list[Episode]:
    shelf = SHELVES[index]
    shelf_path = BASE_DIR / shelf["file"]
    feed_urls = read_lines(shelf_path)
    if not feed_urls:
    raise ValueError(f"{shelf_path.name} にRSS URLがありません。")
    print()
    print(f"棚 {index + 1}: {shelf['label']} ({shelf['short']})")
    print(f"棚ファイル: {shelf_path.name}")
    print(f"RSS数: {len(feed_urls)}")
    all_episodes: list[Episode] = []
    for feed_url in feed_urls:
    print(f"RSS取得中: {feed_url}")
    try:
    rss_data = download_rss(feed_url)
    episodes = parse_rss(rss_data, feed_url)
    all_episodes.extend(episodes)
    print(f" 取得: {len(episodes)} 件")
    except Exception as e:
    print(f" このRSSは読み飛ばしました: {e}")
    if not all_episodes:
    raise ValueError("音声エピソードを1件も取得できませんでした。")
    now = datetime.now().astimezone()
    cutoff = now - timedelta(days=DAYS_BACK)
    filtered = [
    ep for ep in all_episodes
    if ep.pub_date is not None and ep.pub_date >= cutoff
    ]
    if not filtered:
    print(f"過去{DAYS_BACK}日分のエピソードがありません。")
    print("動作確認しやすいように、今回は最新10件でプレイリストを作ります。")
    filtered = sorted(
    all_episodes,
    key=lambda ep: ep.pub_date or datetime.min.replace(tzinfo=timezone.utc),
    reverse=True,
    )[:10]
    filtered.sort(
    key=lambda ep: ep.pub_date or datetime.min.replace(tzinfo=timezone.utc)
    )
    else:
    filtered.sort(
    key=lambda ep: ep.pub_date or datetime.min.replace(tzinfo=timezone.utc)
    )
    return filtered
    def make_playlist(index: int) -> int:
    episodes = collect_episodes_for_shelf(index)
    lines = ["#EXTM3U"]
    for ep in episodes:
    date_text = ep.pub_date.isoformat(timespec="minutes") if ep.pub_date else "日付不明"
    safe_title = ep.title.replace("\n", " ").strip()
    safe_feed = ep.feed_title.replace("\n", " ").strip()
    lines.append(f"# {date_text} / {safe_feed} / {safe_title}")
    lines.append(ep.audio_url)
    PLAYLIST_FILE.write_text(
    "\n".join(lines) + "\n",
    encoding="utf-8",
    )
    print(f"{PLAYLIST_FILE.name} を作成しました。")
    print(f"音声URL数: {len(episodes)} 件")
    return len(episodes)
    def wait_for_mpv_socket(timeout: float = 5.0) -> bool:
    start = time.time()
    while time.time() - start < timeout:
    if os.path.exists(MPV_SOCKET):
    return True
    time.sleep(0.1)
    return False
    def send_mpv_command(command: list[str | int | float]) -> None:
    if not os.path.exists(MPV_SOCKET):
    print("mpvのソケットが見つかりません。mpvがまだ起動していない可能性があります。")
    return
    payload = json.dumps({"command": command}) + "\n"
    try:
    with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
    client.connect(MPV_SOCKET)
    client.sendall(payload.encode("utf-8"))
    except Exception as e:
    print(f"mpvへの命令送信に失敗しました: {e}")
    def stop_mpv() -> None:
    global mpv_process
    if mpv_process is None:
    return
    if mpv_process.poll() is None:
    try:
    send_mpv_command(["quit"])
    mpv_process.wait(timeout=3)
    except Exception:
    mpv_process.terminate()
    try:
    mpv_process.wait(timeout=3)
    except Exception:
    mpv_process.kill()
    mpv_process = None
    if os.path.exists(MPV_SOCKET):
    try:
    os.remove(MPV_SOCKET)
    except OSError:
    pass
    def start_mpv() -> None:
    global mpv_process
    stop_mpv()
    if os.path.exists(MPV_SOCKET):
    os.remove(MPV_SOCKET)
    print("mpvを起動します。")
    mpv_process = subprocess.Popen(
    [
    "mpv",
    "--input-ipc-server=" + MPV_SOCKET,
    "--force-window=no",
    str(PLAYLIST_FILE),
    ]
    )
    if not wait_for_mpv_socket():
    print("mpvの操作ソケットを確認できませんでした。")
    print("再生自体は始まっている可能性があります。")
    def blink_shelf_number(index: int) -> None:
    if shelf_led is None:
    return
    count = index + 1
    print(f"LED点滅: {count}回")
    for _ in range(count):
    shelf_led.on()
    time.sleep(0.18)
    shelf_led.off()
    time.sleep(0.18)
    time.sleep(0.25)
    def blink_error() -> None:
    if shelf_led is None:
    return
    for _ in range(6):
    shelf_led.on()
    time.sleep(0.07)
    shelf_led.off()
    time.sleep(0.07)
    def select_shelf(index: int, restart_player: bool = True) -> bool:
    global current_shelf_index
    with shelf_lock:
    try:
    make_playlist(index)
    current_shelf_index = index
    save_state(index)
    blink_shelf_number(index)
    if restart_player:
    start_mpv()
    print(f"現在の棚: {index + 1} / {SHELVES[index]['label']} ({SHELVES[index]['short']})")
    return True
    except Exception as e:
    print("棚の切り替えに失敗しました。")
    print(e)
    blink_error()
    return False
    def next_shelf() -> None:
    next_index = (current_shelf_index + 1) % len(SHELVES)
    print()
    print(f"棚ボタン: {current_shelf_index + 1} → {next_index + 1}")
    print(f"次の棚: {SHELVES[next_index]['label']} ({SHELVES[next_index]['short']})")
    select_shelf(next_index, restart_player=True)
    def on_play() -> None:
    print("再生/一時停止")
    send_mpv_command(["cycle", "pause"])
    def on_prev() -> None:
    print("戻る")
    send_mpv_command(["playlist-prev"])
    def on_next() -> None:
    print("次へ")
    send_mpv_command(["playlist-next"])
    def list_shelves() -> None:
    print("棚一覧")
    for i, shelf in enumerate(SHELVES, start=1):
    path = BASE_DIR / shelf["file"]
    urls = read_lines(path)
    print(
    f"{i}: {shelf['label']} ({shelf['short']}) "
    f"-> {path.name} RSS数: {len(urls)}"
    )
    def test_led() -> None:
    global shelf_led
    shelf_led = LED(SHELF_LED_GPIO)
    try:
    for index, shelf in enumerate(SHELVES):
    print(f"棚{index + 1}: {shelf['label']} ({shelf['short']}) の点滅テスト")
    blink_shelf_number(index)
    time.sleep(0.5)
    finally:
    shelf_led.off()
    shelf_led.close()
    shelf_led = None
    def main() -> None:
    global current_shelf_index, shelf_led
    parser = argparse.ArgumentParser(description="TanaRadio Pi フェーズ5F")
    parser.add_argument("--list", action="store_true", help="棚一覧を表示する")
    parser.add_argument("--test-led", action="store_true", help="LEDの点滅をテストする")
    args = parser.parse_args()
    if args.list:
    list_shelves()
    return
    if args.test_led:
    test_led()
    return
    current_shelf_index = load_state()
    print("TanaRadio Pi フェーズ5Fを起動します。")
    print()
    list_shelves()
    print()
    print(
    f"前回の棚: {current_shelf_index + 1} / "
    f"{SHELVES[current_shelf_index]['label']} "
    f"({SHELVES[current_shelf_index]['short']})"
    )
    shelf_led = LED(SHELF_LED_GPIO)
    play_button = Button(PLAY_GPIO, pull_up=True, bounce_time=0.08)
    prev_button = Button(PREV_GPIO, pull_up=True, bounce_time=0.08)
    next_button = Button(NEXT_GPIO, pull_up=True, bounce_time=0.08)
    shelf_button = Button(SHELF_GPIO, pull_up=True, bounce_time=0.12)
    play_button.when_pressed = on_play
    prev_button.when_pressed = on_prev
    next_button.when_pressed = on_next
    shelf_button.when_pressed = next_shelf
    try:
    ok = select_shelf(current_shelf_index, restart_player=True)
    if not ok:
    print("最初の棚を作れませんでした。棚ファイルを確認してください。")
    return
    print()
    print("操作できます。")
    print("再生/一時停止、戻る、次へ、棚選びボタンを押してください。")
    print("終了するには Ctrl + C を押します。")
    signal.pause()
    except KeyboardInterrupt:
    print()
    print("終了します。")
    finally:
    stop_mpv()
    if shelf_led is not None:
    shelf_led.off()
    shelf_led.close()
    play_button.close()
    prev_button.close()
    next_button.close()
    shelf_button.close()
    if __name__ == "__main__":
    main()
    ```
    保存します。
    ```text
    Ctrl + O
    Enter
    Ctrl + X
    ```
    ---
    # 11. 実行権限を付ける
    次を実行します。
    ```bash
    chmod +x tanaradio_5f.py
    ```
    ただし、実行は `python3` で行うのが安全です。
    ```bash
    python3 tanaradio_5f.py
    ```
    以前のように、Pythonファイルをシェルスクリプトとして読んでしまう事故を避けるためです。
    ---
    # 12. 棚一覧を確認する
    まず、棚ファイルが正しく認識されているか確認します。
    ```bash
    python3 tanaradio_5f.py --list
    ```
    期待される表示例です。
    ```text
    棚一覧
    1: 声日記・生活 (voice) -> shelf_01_voice_life.txt RSS数: 8
    2: 技術・AI・ものづくり (tech) -> shelf_02_tech_ai_mono.txt RSS数: 6
    3: 教育・研究・知の実践 (edu) -> shelf_03_education_research.txt RSS数: 5
    4: 本・文化・物語 (books) -> shelf_04_books_culture.txt RSS数: 4
    5: TanaRadio (tana) -> shelf_05_tanaradio.txt RSS数: 1
    ```
    RSS数は実際のファイル内容によって変わります。
    ここで重要なのは、次の対応が正しく出ることです。
    ```text
    1: 声日記・生活 -> shelf_01_voice_life.txt
    2: 技術・AI・ものづくり -> shelf_02_tech_ai_mono.txt
    3: 教育・研究・知の実践 -> shelf_03_education_research.txt
    4: 本・文化・物語 -> shelf_04_books_culture.txt
    5: TanaRadio -> shelf_05_tanaradio.txt
    ```
    ---
    # 13. LEDだけテストする
    いきなり再生まで行かず、まずLEDの点滅を確認します。
    ```bash
    python3 tanaradio_5f.py --test-led
    ```
    期待される動きです。
    ```text
    棚1:LEDが1回点滅
    棚2:LEDが2回点滅
    棚3:LEDが3回点滅
    棚4:LEDが4回点滅
    棚5:LEDが5回点滅
    ```
    表示例です。
    ```text
    棚1: 声日記・生活 (voice) の点滅テスト
    LED点滅: 1回
    棚2: 技術・AI・ものづくり (tech) の点滅テスト
    LED点滅: 2回
    棚3: 教育・研究・知の実践 (edu) の点滅テスト
    LED点滅: 3回
    棚4: 本・文化・物語 (books) の点滅テスト
    LED点滅: 4回
    棚5: TanaRadio (tana) の点滅テスト
    LED点滅: 5回
    ```
    LEDが光らない場合は、次を確認します。
    ```text
    LEDの向きが逆ではないか
    GPIO24ではなく別のピンにつないでいないか
    GNDにつながっているか
    抵抗を入れているか
    ```
    ---
    # 14. 5Fを起動する
    いよいよ5Fを起動します。
    ```bash
    python3 tanaradio_5f.py
    ```
    うまくいくと、次のような表示になります。
    ```text
    TanaRadio Pi フェーズ5Fを起動します。
    棚一覧
    1: 声日記・生活 (voice) -> shelf_01_voice_life.txt RSS数: 8
    2: 技術・AI・ものづくり (tech) -> shelf_02_tech_ai_mono.txt RSS数: 6
    3: 教育・研究・知の実践 (edu) -> shelf_03_education_research.txt RSS数: 5
    4: 本・文化・物語 (books) -> shelf_04_books_culture.txt RSS数: 4
    5: TanaRadio (tana) -> shelf_05_tanaradio.txt RSS数: 1
    前回の棚: 1 / 声日記・生活 (voice)
    棚 1: 声日記・生活 (voice)
    棚ファイル: shelf_01_voice_life.txt
    RSS数: 8
    RSS取得中: ...
    playlist.m3u を作成しました。
    音声URL数: 10 件
    LED点滅: 1回
    mpvを起動します。
    現在の棚: 1 / 声日記・生活 (voice)
    操作できます。
    再生/一時停止、戻る、次へ、棚選びボタンを押してください。
    終了するには Ctrl + C を押します。
    ```
    音が出れば、まず成功です。
    ---
    # 15. 既存3ボタンを確認する
    次のボタンを押します。
    ```text
    再生/一時停止
    戻る
    次へ
    ```
    ターミナルに次のように表示されれば、ボタン入力は読めています。
    ```text
    再生/一時停止
    戻る
    次へ
    ```
    音声もそれに合わせて動けばOKです。
    ---
    # 16. 棚選びボタンを押す
    棚選びボタンを1回押します。
    棚1から棚2に進む場合、期待される動きは次の通りです。
    ```text
    棚1:声日記・生活
    ↓
    棚2:技術・AI・ものづくり
    ```
    ターミナルには、たとえば次のように表示されます。
    ```text
    棚ボタン: 1 → 2
    次の棚: 技術・AI・ものづくり (tech)
    棚 2: 技術・AI・ものづくり (tech)
    棚ファイル: shelf_02_tech_ai_mono.txt
    RSS数: 6
    RSS取得中: ...
    playlist.m3u を作成しました。
    音声URL数: 12 件
    LED点滅: 2回
    mpvを起動します。
    現在の棚: 2 / 技術・AI・ものづくり (tech)
    ```
    さらに押すと、次のように進みます。
    ```text
    2 → 3:教育・研究・知の実践
    3 → 4:本・文化・物語
    4 → 5:TanaRadio
    5 → 1:声日記・生活
    ```
    LED点滅の対応は次の通りです。
    ```text
    1回点滅:声日記・生活
    2回点滅:技術・AI・ものづくり
    3回点滅:教育・研究・知の実践
    4回点滅:本・文化・物語
    5回点滅:TanaRadio
    ```
    ---
    # 17. 前回の棚が保存されるか確認する
    一度終了します。
    ```text
    Ctrl + C
    ```
    もう一度起動します。
    ```bash
    python3 tanaradio_5f.py
    ```
    前回最後に選んだ棚から始まれば成功です。
    棚の状態は、次のファイルに保存されます。
    ```text
    state_5f.json
    ```
    中身を確認するには、次を実行します。
    ```bash
    cat state_5f.json
    ```
    表示例です。
    ```json
    {
    "shelf_index": 1,
    "shelf_label": "技術・AI・ものづくり",
    "shelf_short": "tech",
    "shelf_file": "shelf_02_tech_ai_mono.txt",
    "saved_at": "2026-07-06T09:30:00"
    }
    ```
    ここで `shelf_index` は0始まりです。
    つまり、
    ```text
    shelf_index 0:棚1
    shelf_index 1:棚2
    shelf_index 2:棚3
    shelf_index 3:棚4
    shelf_index 4:棚5
    ```
    です。
    ---
    # 18. 成功条件
    フェーズ5Fの成功条件は、次の4つです。
    ```text
    1. 棚選びボタンを押すと、棚番号が進む
    2. LED1が棚番号ぶん点滅する
    3. 選ばれた棚ファイルに応じて playlist.m3u が作り直される
    4. mpvが新しい棚で再生し直される
    ```
    棚番号の対応は次の通りです。
    ```text
    1:声日記・生活
    2:技術・AI・ものづくり
    3:教育・研究・知の実践
    4:本・文化・物語
    5:TanaRadio
    ```
    LED点滅の対応は次の通りです。
    ```text
    1回点滅:声日記・生活
    2回点滅:技術・AI・ものづくり
    3回点滅:教育・研究・知の実践
    4回点滅:本・文化・物語
    5回点滅:TanaRadio
    ```
    ---
    # 20. 今回はここで止めてよい
    フェーズ5Fでは、ここまでで十分です。
    今回できるようになることは、次です。
    ```text
    物理ボタンで棚を選べる
    LEDの点滅で棚番号を確認できる
    棚ごとのRSSリストからプレイリストを作れる
    選んだ棚をmpvで再生し直せる
    ```
    これはかなり大きな進歩です。
    フェーズ5Eまでは、棚はまだ「コマンドで指定するもの」でした。
    フェーズ5Fで、棚は「手元のボタンで選ぶもの」になります。
    ここから一気にラジオ感が出ます。
    次のフェーズ5Gでは、5つ目のボタンとLED2を追加して、再生モードを選べるようにします。
    候補は、まず次の3つで十分です。
    ```text
    1:最新順
    2:今日
    3:3日前から古い順
    ```
    「7日前から古い順」や「未聴っぽいもの」は、そのあとで大丈夫です。
    ここで焦らないのが勝ち筋です。

    #声日記 #TanaRadioPi

    LISTENで開く
    27 min

About TanaRadio

From the publisher's feed

私立工科系大学教員(科学史)の日々の出来事や雑感。日記,メモ,ブログなど