PODCAST · society
TanaRadio
by たな
私立工科系大学教員(科学史)の日々の出来事や雑感。日記,メモ,ブログなどTanaRadio Blog ( https://tanakahi.hatenablog.com/ )
-
377
384 日記 | 基板へのハンダ付けとスイッチ動作確認
基板への部品ハンダ付けの続き。今日はタクトスイッチ1つと抵抗1つを取り付け,スイッチの動作確認をしました。#声日記 #TanaRadioPi LISTENで開く
-
376
383 日記 | 基板に部品をハンダ付け
基板に部品をハンダ付けする作業を始めました。まずは,ピンヘッダーとグラウンド線です。#声日記 #TanaRadioPi LISTENで開く
-
375
382 日記 | 基板に部品を配置
昨日作った実体配線図通りには基板に置けなかったので,一部配置を変えました。#声日記 #TanaRadioPi LISTENで開く
-
374
381 日記 | 回路図を書いてみました
2016年7月13日月曜日の声日記。TanaRadio Piの回路図(正確には実体配線図)など。カゴラジオ#声日記 #TanaRadioPi LISTENで開く
-
373
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接続します。 ssh [email protected] 入れたら、状態確認はこれです。 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で開く
-
372
379 日記 | Raspberry Piをケースに入れる
フェーズ5Jに行こうとしましたが,その前にRaspberry Piをケースに入れるところまでで終わってしまいました。*なぜかノイズキャンセリングが効いているようで,とても聴きにくいです。#声日記 #TanaRadioPi LISTENで開く
-
371
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で開く
-
370
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で開く
-
369
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で開く
-
368
375 日記 | 棚を選択してプレイリストを作成
5つの棚(ポッドキャストのグループ)を作成し,それを選んでプレイリストを作成できるようにしました(フェーズ5E完了)。フェーズ5E 詳細マニュアル 棚ファイルを作り、コマンドで棚を切り替える 0. 今回の目標 フェーズ5Eでやることは、これです。 棚ごとのRSSリストを作る ↓ コマンドで棚を指定する ↓ 指定した棚のRSSだけを読む ↓ 過去3日分・古い順で playlist.m3u を作る ↓ mpv または tanaradio_5d.py で再生する 今回はまだ、以下はやりません。 棚選びボタン LED点滅表示 再生モード選び ローカル再生履歴 ロータリーエンコーダー ここは焦らなくてよいです。 5Eの目的は、「棚」という考え方を、まずファイルで実現することです。 ステップ1:作業フォルダへ移動する Raspberry Piでターミナルを開きます。 cd ~/tanaradio5 確認します。 pwd 次のように表示されればOKです。 /home/ユーザー名/tanaradio5 ファイルも確認します。 ls 少なくとも、次のようなファイルがあるはずです。 feeds.txt make_playlist_5c.py playlist.m3u tanaradio_5d.py ステップ2:今の状態をバックアップする 5Dまで動いているので、念のためバックアップします。 cp make_playlist_5c.py make_playlist_5c_backup.py cp tanaradio_5d.py tanaradio_5d_backup.py cp playlist.m3u playlist_backup_5d.m3u 確認します。 ls バックアップファイルが見えればOKです。 make_playlist_5c_backup.py tanaradio_5d_backup.py playlist_backup_5d.m3u 5Eでは tanaradio_5d.py は基本的に変更しません。 ここ、大事です。5Dで動いたものはそのまま残します。動いたものは宝です。Linux作業では、動いたものを消すと、静かに深い後悔が来ます。 ステップ3:棚ファイルを作る まず、今回決めた5つの棚に対応するファイルを作ります。 touch shelf_01_voice_life.txt touch shelf_02_tech_ai_mono.txt touch shelf_03_education_research.txt touch shelf_04_books_culture.txt touch shelf_05_tanaradio.txt 確認します。 ls shelf_*.txt 次のように表示されれば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 棚の意味は次の通りです。 1:声日記・生活 2:技術・AI・ものづくり 3:教育・研究・知の実践 4:本・文化・物語 5:TanaRadio ステップ4:まず1つの棚にRSSを入れる 最初から全部きれいに分類しなくてよいです。 まずは、動作確認のために、これまで使っていた feeds.txt を1番の棚にコピーします。 cp feeds.txt shelf_01_voice_life.txt 確認します。 cat shelf_01_voice_life.txt RSS URLが表示されればOKです。 あとで、実際の分類に合わせて中身を編集します。 たとえば、1番の棚を編集するには、 nano shelf_01_voice_life.txt 次のように、コメントを入れながらRSS URLを書けます。 # 声日記・生活 # で始まる行は、あとで作るプログラムでは無視されます。 つまり、番組名メモを書いておいて大丈夫です。 ステップ5:5E用スクリプトを作る 新しいPythonファイルを作ります。 nano make_playlist_5e.py 次のコードをそのまま貼り付けてください。 #!/usr/bin/env python3 import sys import urllib.request import xml.etree.ElementTree as ET from pathlib import Path from datetime import datetime, timedelta, timezone from email.utils import parsedate_to_datetime from zoneinfo import ZoneInfo PLAYLIST_FILE = "playlist.m3u" # 5Eでは、まだ再生モード選びはしない。 # 5Cと同じく「過去3日分・古い順」で固定する。 DAYS = 3 JST = ZoneInfo("Asia/Tokyo") SHELVES = [ { "id": "1", "key": "voice", "name": "声日記・生活", "file": "shelf_01_voice_life.txt", "aliases": ["life", "koe", "nikki"], }, { "id": "2", "key": "tech", "name": "技術・AI・ものづくり", "file": "shelf_02_tech_ai_mono.txt", "aliases": ["ai", "mono", "monozukuri"], }, { "id": "3", "key": "edu", "name": "教育・研究・知の実践", "file": "shelf_03_education_research.txt", "aliases": ["education", "research", "study"], }, { "id": "4", "key": "books", "name": "本・文化・物語", "file": "shelf_04_books_culture.txt", "aliases": ["book", "culture", "story"], }, { "id": "5", "key": "tana", "name": "TanaRadio", "file": "shelf_05_tanaradio.txt", "aliases": ["tanaradio"], }, ] def show_shelves(): print("使える棚:") for shelf in SHELVES: print( f" {shelf['id']}: {shelf['name']} " f"({shelf['key']}) -> {shelf['file']}" ) print() print("例:") print(" python3 make_playlist_5e.py 1") print(" python3 make_playlist_5e.py tech") print(" python3 make_playlist_5e.py shelf_05_tanaradio.txt") def resolve_shelf(argument): if argument is None: # 引数なしの場合は1番棚を使う return SHELVES[0] arg = argument.strip() for shelf in SHELVES: candidates = [shelf["id"], shelf["key"]] + shelf["aliases"] if arg in candidates: return shelf # .txt が指定された場合は、任意の棚ファイルとして扱う path = Path(arg) if path.suffix == ".txt" or path.exists(): return { "id": "?", "key": "custom", "name": path.stem, "file": str(path), "aliases": [], } raise ValueError(f"棚を見つけられませんでした: {argument}") def read_feed_urls(shelf_file): path = Path(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): print(f"RSSを取得します: {url}") request = urllib.request.Request( url, headers={ "User-Agent": "TanaRadio-Pi/5E" } ) with urllib.request.urlopen(request, timeout=20) as response: return response.read() def get_text(element, tag_name): child = element.find(tag_name) if child is not None and child.text: return child.text.strip() return "" def find_audio_url(item): # 通常のPodcast RSSでは enclosure に音声URLが入る 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(item): pub_date_text = get_text(item, "pubDate") if not pub_date_text: return None try: dt = parsedate_to_datetime(pub_date_text) # タイムゾーン情報がない場合はUTC扱いにする if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(JST) except Exception: return None def parse_rss(rss_data, feed_url): root = ET.fromstring(rss_data) channel = root.find("channel") if channel is None: raise ValueError("RSSのchannelが見つかりません。") program_title = get_text(channel, "title") if not program_title: program_title = "番組名不明" items = channel.findall("item") episodes = [] for item in items: episode_title = get_text(item, "title") audio_url = find_audio_url(item) pub_dt = parse_pub_date(item) if not audio_url: continue if pub_dt is None: continue episodes.append( { "program_title": program_title, "episode_title": episode_title, "audio_url": audio_url, "pub_dt": pub_dt, "feed_url": feed_url, } ) return episodes def collect_episodes(feed_urls): all_episodes = [] for url in feed_urls: try: rss_data = download_rss(url) episodes = parse_rss(rss_data, url) all_episodes.extend(episodes) print(f" 取得できたエピソード数: {len(episodes)} 件") except Exception as e: print(" このRSSの取得または解析でエラーが発生しました。") print(f" {e}") print(" 次のRSSへ進みます。") return all_episodes def remove_duplicates(episodes): seen = set() unique = [] for ep in episodes: audio_url = ep["audio_url"] if audio_url in seen: continue seen.add(audio_url) unique.append(ep) return unique def filter_recent_episodes(episodes): now = datetime.now(JST) cutoff = now - timedelta(days=DAYS) recent = [] for ep in episodes: if ep["pub_dt"] >= cutoff: recent.append(ep) return recent def make_playlist(episodes, shelf): if not episodes: raise ValueError( f"過去{DAYS}日分のエピソードが見つかりませんでした。" ) # 古い順に並べる episodes.sort(key=lambda ep: ep["pub_dt"]) playlist_lines = [ "#EXTM3U", f"# Shelf: {shelf['name']}", f"# Shelf file: {shelf['file']}", f"# Mode: 過去{DAYS}日分・古い順", ] for ep in episodes: pub_text = ep["pub_dt"].strftime("%Y-%m-%d %H:%M") program = ep["program_title"] title = ep["episode_title"] url = ep["audio_url"] playlist_lines.append(f"# {pub_text} / {program} / {title}") playlist_lines.append(url) Path(PLAYLIST_FILE).write_text( "\n".join(playlist_lines) + "\n", encoding="utf-8" ) print(f"{PLAYLIST_FILE} を作成しました。") print(f"プレイリスト内のエピソード数: {len(episodes)} 件") def main(): try: if len(sys.argv) >= 2 and sys.argv[1] in ["--list", "-l"]: show_shelves() return argument = sys.argv[1] if len(sys.argv) >= 2 else None shelf = resolve_shelf(argument) print("選択された棚:") print(f" 番号: {shelf['id']}") print(f" 名前: {shelf['name']}") print(f" ファイル: {shelf['file']}") print() feed_urls = read_feed_urls(shelf["file"]) print(f"RSS URL数: {len(feed_urls)} 本") episodes = collect_episodes(feed_urls) print(f"全取得エピソード数: {len(episodes)} 件") episodes = remove_duplicates(episodes) print(f"重複除去後: {len(episodes)} 件") episodes = filter_recent_episodes(episodes) print(f"過去{DAYS}日分: {len(episodes)} 件") make_playlist(episodes, shelf) print("完了しました。") except Exception as e: print("エラーが発生しました。") print(e) print() print("使い方を確認するには、次を実行してください。") print(" python3 make_playlist_5e.py --list") if __name__ == "__main__": main() 保存します。 Ctrl + O Enter Ctrl + X ステップ6:実行権限をつける chmod +x make_playlist_5e.py ただし、最初の実行は今回も python3 をつけるのがおすすめです。 python3 make_playlist_5e.py --list 次のように表示されればOKです。 使える棚: 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 ステップ7:1番棚でプレイリストを作る まず、1番棚を使います。 python3 make_playlist_5e.py 1 または、 python3 make_playlist_5e.py voice うまくいくと、次のような表示になります。 選択された棚: 番号: 1 名前: 声日記・生活 ファイル: shelf_01_voice_life.txt RSS URL数: 5 本 RSSを取得します: https://... 取得できたエピソード数: 10 件 ... 全取得エピソード数: 50 件 重複除去後: 50 件 過去3日分: 12 件 playlist.m3u を作成しました。 プレイリスト内のエピソード数: 12 件 完了しました。 確認します。 head -n 20 playlist.m3u 次のように、棚名が入っていればOKです。 #EXTM3U # Shelf: 声日記・生活 # Shelf file: shelf_01_voice_life.txt # Mode: 過去3日分・古い順 # 2026-07-03 18:21 / 1分声日記 / 2026/07/03 18:21 https://... ここで、5Eの基本は成功です。 ステップ8:mpvで再生確認する まずは単純にmpvで再生します。 mpv playlist.m3u 音が出ればOKです。 mpvの操作はこれまで通りです。 スペースキー:一時停止/再開 Enter:次へ q:終了 確認できたら、q で終了します。 ステップ9:5Dの物理ボタン操作につなげる 5Eで作った playlist.m3u は、5Dの tanaradio_5d.py でそのまま使えます。 つまり、次の流れです。 python3 make_playlist_5e.py 1 ./tanaradio_5d.py これで、 1番棚のRSSから playlist.m3u を作る ↓ tanaradio_5d.py が playlist.m3u を再生する ↓ 3つの物理ボタンで操作する という形になります。 ボタン操作は5Dと同じです。 再生/一時停止ボタン:再生・一時停止 戻るボタン:前のエピソードへ 次へボタン:次のエピソードへ ここまでできると、TanaRadio Piはもうかなり「棚を持ったラジオ」になっています。 ステップ10:別の棚を試す 2番棚にRSSを入れてから試します。 nano shelf_02_tech_ai_mono.txt たとえば、次のように書きます。 # 技術・AI・ものづくり # ・・・ https://listen.style/p/xxxxx/rss # ・・・ https://listen.style/p/yyyyy/rss # ・・・ https://listen.style/p/zzzzz/rss 保存します。 Ctrl + O Enter Ctrl + X 2番棚でプレイリストを作ります。 python3 make_playlist_5e.py 2 または、 python3 make_playlist_5e.py tech 確認します。 head -n 20 playlist.m3u Shelf: 技術・AI・ものづくり と表示されればOKです。 再生します。 mpv playlist.m3u または、物理ボタンつきで再生します。 ./tanaradio_5d.py ステップ11:5つの棚を順番に整える 最初の棚構成は、次の形でよいと思います。 1:声日記・生活 nano shelf_01_voice_life.txt 入れる候補: 2:技術・AI・ものづくり nano shelf_02_tech_ai_mono.txt 入れる候補: 3:教育・研究・知の実践 nano shelf_03_education_research.txt 入れる候補: 4:本・文化・物語 nano shelf_04_books_culture.txt 入れる候補: 5:TanaRadio nano shelf_05_tanaradio.txt 入れる候補: TanaRadio 同じ番組を複数の棚に入れても大丈夫です。 棚は図書館の分類ではありません。 聴くときの気分で使う分類です。ここはゆるくて大丈夫です。 ステップ12:棚ごとの動作確認 それぞれの棚で試します。 python3 make_playlist_5e.py 1 head -n 10 playlist.m3u python3 make_playlist_5e.py 2 head -n 10 playlist.m3u python3 make_playlist_5e.py 3 head -n 10 playlist.m3u python3 make_playlist_5e.py 4 head -n 10 playlist.m3u python3 make_playlist_5e.py 5 head -n 10 playlist.m3u それぞれで、 # Shelf: ... の部分が変わればOKです。 たとえば、 # Shelf: 声日記・生活 # Shelf: 技術・AI・ものづくり # Shelf: TanaRadio のように変われば成功です。 ステップ13:実際の使い方 フェーズ5E段階での日常運用は、こうなります。 声日記・生活を聴く cd ~/tanaradio5 python3 make_playlist_5e.py 1 ./tanaradio_5d.py 技術・AI・ものづくりを聴く cd ~/tanaradio5 python3 make_playlist_5e.py 2 ./tanaradio_5d.py TanaRadioを聴く cd ~/tanaradio5 python3 make_playlist_5e.py 5 ./tanaradio_5d.py ここではまだ「棚を選んでから再生」ではなく、**「棚を指定してプレイリストを作ってから再生」**です。 次の5Fで、これを棚選びボタンに接続します。 フェーズ5Eの成功条件 フェーズ5Eの成功条件は、次の5つです。 1. shelf_01〜shelf_05 の棚ファイルを作れた 2. 棚ファイルにRSS URLを書けた 3. make_playlist_5e.py で棚を指定できた 4. 指定した棚に応じて playlist.m3u が作り直された 5. tanaradio_5d.py で、その playlist.m3u を物理ボタン操作できた ここまでできれば、5Eは成功です。 今回できるようになること 5Eが成功すると、TanaRadio Piはこう変わります。 フォロー中をまとめて流す装置 から、 声の棚を選んで流す装置 になります。 これはかなり大きいです。 まだ棚選びボタンはありませんが、考え方としてはもう「本棚」です。 今回の5Eを一言で言えば、こうです。 RSS一覧を、意味のある棚に分ける。 ここからTanaRadio Piは、単なるポッドキャスト再生機ではなく、たなさんの生活・研究・制作に合わせて育つ「声の本棚」になっていきます。次の5Fでは、この棚切り替えを物理ボタンに接続します。#声日記 #TanaRadioPi LISTENで開く
-
367
374 日記 | 物理ボタンでプレイリスト再生を制御
物理ボタンでプレイリスト再生の制御ができるようになりました(フェーズ5D完了)。*以下のtanaradio_5d.pyのコードは,修正済みのものです(この収録後も1回修正しました)。フェーズ5D 詳細マニュアル 既存3ボタンでRSSプレイリストを操作する 0. 今回の目標 今回やることは、これです。 フェーズ5Cで playlist.m3u を作る ↓ mpvで playlist.m3u を再生する ↓ GPIOの3ボタンでmpvを操作する 3つのボタンの役割は、今回は次のようにします。 再生/一時停止ボタン:再生・一時停止 戻るボタン:前のエピソードへ 次へボタン:次のエピソードへ mpvへの命令は次の3つです。 再生/一時停止:cycle pause 戻る:playlist-prev 次へ:playlist-next 構想メモでは、戻るボタンに「1回押し=先頭へ、2回押し=前へ、長押し=10秒戻る」という発展案があります。 ただし、5Dではまだ入れません。ここで欲張ると、ボタン判定の沼に入ります。沼は楽しいですが、今日はまだ岸辺でお茶を飲みます。 ステップ1:作業フォルダへ移動する Raspberry Piでターミナルを開きます。 cd ~/tanaradio5 確認します。 pwd 次のような表示になればOKです。 /home/ユーザー名/tanaradio5 ステップ2:5Cまでのファイルを確認する ls 少なくとも、次のようなファイルがあるはずです。 feeds.txt make_playlist_5c.py playlist.m3u スクリプト名は、たなさんが5Cで作った名前に合わせてください。 以下では、5Cのスクリプト名を make_playlist_5c.py として進めます。 ステップ3:5Cのプレイリストをもう一度作る まず、5Cのスクリプトを実行して、最新の playlist.m3u を作ります。 ./make_playlist_5c.py または、実行権限を付けていない場合は、 python3 make_playlist_5c.py うまくいけば、たとえば次のような表示になります。 playlist.m3u を作成しました。 音声URL数: 12 件 完了しました。 確認します。 ls -l playlist.m3u playlist.m3u が存在し、サイズが0でなければOKです。 中身も少し見ます。 head playlist.m3u 次のように、音声URLが入っていればOKです。 #EXTM3U # エピソードタイトル https://... ステップ4:mpvだけで再生できるか確認する ボタン操作に入る前に、まず普通に再生します。 mpv playlist.m3u 音が出ればOKです。 この状態では、キーボードで操作できます。 スペースキー:一時停止/再開 Enter:次へ q:終了 確認できたら、q を押してmpvを終了します。 ここで音が出ない場合は、5Dには進まず、音声出力側を先に確認してください。 ステップ5:GPIOライブラリを確認する 今回はPythonからGPIOボタンを読むために gpiozero を使います。 まず確認します。 python3 -c "import gpiozero; print('gpiozero OK')" 次のように出ればOKです。 gpiozero OK もしエラーが出たら、次を実行します。 sudo apt update sudo apt install -y python3-gpiozero python3-lgpio インストール後、もう一度確認します。 python3 -c "import gpiozero; print('gpiozero OK')" ステップ6:GPIO番号を確認する 今回のコードでは、フェーズ4で使ってきた3つのGPIOを次のように使います。 GPIO17:再生/一時停止 GPIO27:戻る、つまり前のエピソードへ GPIO22:次へ、つまり次のエピソードへ 配線はこれまで通り、基本形は次です。構想メモでも、ボタン入力には保険として抵抗を残す方針になっています。 GPIO ─ 1kΩ程度の抵抗 ─ ボタン ─ GND つまり、ボタンを押すとGPIOがGNDにつながる形です。 Python側では pull_up=True にします。 ステップ7:5D用スクリプトを作る 新しいファイルを作ります。 nano tanaradio_5d.py 次のコードをそのまま貼り付けます。 #!/usr/bin/env python3 from gpiozero import Button from signal import pause import json import os import signal import socket import subprocess import sys import time from pathlib import Path # ===== 設定ここから ===== BASE_DIR = Path(__file__).resolve().parent PLAYLIST_FILE = BASE_DIR / "playlist.m3u" SOCKET_PATH = "/tmp/tanaradio-mpv.sock" # フェーズ4で使った既存3ボタンのGPIO番号 PIN_PLAY_PAUSE = 17 # 再生/一時停止 PIN_BACK = 27 # 戻る:前のエピソードへ PIN_NEXT = 22 # 次へ:次のエピソードへ # ===== 設定ここまで ===== mpv_process = None def check_playlist(): if not PLAYLIST_FILE.exists(): print(f"{PLAYLIST_FILE} が見つかりません。") print("先にフェーズ5Cのスクリプトを実行して playlist.m3u を作ってください。") sys.exit(1) text = PLAYLIST_FILE.read_text(encoding="utf-8", errors="ignore").strip() if not text: print(f"{PLAYLIST_FILE} は空です。") sys.exit(1) def start_mpv(): global mpv_process if os.path.exists(SOCKET_PATH): os.remove(SOCKET_PATH) print("mpvを起動します。") print(f"プレイリスト: {PLAYLIST_FILE}") mpv_process = subprocess.Popen( [ "mpv", "--no-video", f"--input-ipc-server={SOCKET_PATH}", str(PLAYLIST_FILE), ], cwd=str(BASE_DIR), ) # mpvのIPCソケットができるまで少し待つ for _ in range(50): if os.path.exists(SOCKET_PATH): print("mpv操作用ソケットを確認しました。") return time.sleep(0.1) print("mpv操作用ソケットが見つかりません。") print("mpvが起動できていない可能性があります。") def send_mpv_command(command): payload = json.dumps({"command": command}).encode("utf-8") + b"\n" try: with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: client.settimeout(1.0) client.connect(SOCKET_PATH) client.sendall(payload) # mpvからの返事を少し読む。 # これをしないと、mpv側に Broken pipe が出ることがある。 try: client.recv(4096) except socket.timeout: pass except FileNotFoundError: print("mpvのソケットが見つかりません。mpvが終了している可能性があります。") except ConnectionRefusedError: print("mpvに接続できませんでした。") except Exception as e: print("mpvへの命令送信でエラーが出ました。") print(e) def play_pause(): print("ボタン: 再生/一時停止") send_mpv_command(["cycle", "pause"]) def back_episode(): print("ボタン: 戻る:前のエピソードへ") send_mpv_command(["playlist-prev"]) def next_episode(): print("ボタン: 次へ") send_mpv_command(["playlist-next"]) def setup_buttons(): print("GPIOボタンを準備します。") print(f"再生/一時停止: GPIO{PIN_PLAY_PAUSE}") print(f"戻る: GPIO{PIN_BACK}") print(f"次へ: GPIO{PIN_NEXT}") play_button = Button(PIN_PLAY_PAUSE, pull_up=True, bounce_time=0.08) back_button = Button(PIN_BACK, pull_up=True, bounce_time=0.08) next_button = Button(PIN_NEXT, pull_up=True, bounce_time=0.08) play_button.when_pressed = play_pause back_button.when_pressed = back_episode next_button.when_pressed = next_episode # main側で保持するために返す return play_button, back_button, next_button def cleanup(signum=None, frame=None): global mpv_process print("\n終了します。") if mpv_process is not None and mpv_process.poll() is None: mpv_process.terminate() try: mpv_process.wait(timeout=5) except subprocess.TimeoutExpired: mpv_process.kill() if os.path.exists(SOCKET_PATH): try: os.remove(SOCKET_PATH) except OSError: pass sys.exit(0) def main(): signal.signal(signal.SIGINT, cleanup) signal.signal(signal.SIGTERM, cleanup) check_playlist() start_mpv() # 重要: # setup_buttons() の戻り値を変数に入れて保持する。 # これをしないと、環境によってはボタンが反応しなくなることがある。 buttons = setup_buttons() print("準備完了です。") print("3つの物理ボタンで操作できます。終了は Ctrl + C です。") pause() if __name__ == "__main__": main() 保存します。 Ctrl + O Enter Ctrl + X ステップ8:実行権限を付ける chmod +x tanaradio_5d.py ステップ9:5Dを実行する ./tanaradio_5d.py うまくいくと、次のような表示になります。 mpvを起動します。 プレイリスト: /home/ユーザー名/tanaradio5/playlist.m3u mpv操作用ソケットを確認しました。 GPIOボタンを準備します。 再生/一時停止: GPIO17 戻る: GPIO27 次へ: GPIO22 準備完了です。 3つの物理ボタンで操作できます。終了は Ctrl + C です。 この状態で、音声が流れ始めます。 ステップ10:3つのボタンを試す 10-1. 再生/一時停止ボタン GPIO17につながっているボタンを押します。 ターミナルに次のように出ればOKです。 ボタン: 再生/一時停止 音声が一時停止します。 もう一度押すと再開します。 10-2. 次へボタン GPIO22につながっているボタンを押します。 ボタン: 次へ 次のエピソードへ進めばOKです。 10-3. 戻るボタン GPIO27につながっているボタンを押します。 ボタン: 戻る:前のエピソードへ 前のエピソードへ戻ればOKです。 ただし、プレイリストの最初のエピソードを再生しているときは、それ以上前へ戻れないので、動きがわかりにくい場合があります。 その場合は、先に「次へ」を1〜2回押してから「戻る」を試してください。 ステップ11:終了する 終了するときは、ターミナルで次を押します。 Ctrl + C 次のように出ればOKです。 終了します。 mpvも一緒に終了します。 ステップ12:今回の成功条件 フェーズ5Dの成功条件は、次の4つです。 1. フェーズ5Cの playlist.m3u が作れている 2. tanaradio_5d.py を実行すると mpv が起動する 3. 再生/一時停止ボタンで pause / resume ができる 4. 戻る・次へボタンで playlist-prev / playlist-next ができる ここまでできれば、5Dは成功です。 これはかなり大きいです。 5Cまでは「RSSから音声を集める仕組み」でしたが、5Dでついに「物理ボタンで声の流れを操る装置」になります。ラジオ感が、ぐっと前に出てきます。#声日記 #TanaRadioPi LISTENで開く
-
366
373 講義 | 「ほどよい」技術
この音源は、最近収録した今学期の「科学と技術の社会史」の講義(ビデオ教材)の最後の部分です。現在製作中のTanaRadio Piの話も出てきます。#講義 #TanaRadioPi LISTENで開く
-
365
372 日記 | 複数RSSで過去3日分を古い順に再生
複数RSSで過去3日分を古い順に再生できるようになりまいした(フェーズ5C完了)。*NHKの番組を再生している部分はカットしました。フェーズ5C 詳細マニュアル 複数RSSから「過去3日分・古い順」のプレイリストを作る 0. 今回の目標 5Cでやることは、これです。 複数のRSS URLを feeds.txt に書く ↓ PythonでRSSを順番に読む ↓ 各RSSからエピソードを集める ↓ 過去3日分だけに絞る ↓ 公開日時の古い順に並べる ↓ playlist.m3u を作る ↓ mpvで連続再生する 今回も、まだやらないことがあります。 棚選び 再生モード選び LED表示 追加ボタン ロータリーエンコーダー 再生履歴 ここ、大事です。 5Cは「複数RSS化だけ」です。まだラジオの操作部品は増やしません。焦らないのが勝ち筋です。 ステップ1:作業フォルダへ移動する Raspberry Piでターミナルを開きます。 cd ~/tanaradio5 確認します。 pwd 次のような表示ならOKです。 /home/pi/tanaradio5 ユーザー名が違う場合は、pi の部分は別名になります。 ステップ2:5Bまでのファイルを確認する ls おそらく、次のようなファイルがあるはずです。 feed.txt make_playlist_5a.py make_playlist_5b.py playlist.m3u 5Cでは、5Bのファイルを壊さず、新しく次の2つを作ります。 feeds.txt make_playlist_5c.py 5Bはそのまま残します。 動いたものは残す。これは電子工作でもプログラムでも鉄則です。動いたものを消すと、あとで泣きます。小さく泣くならまだしも、Linux相手だとわりと本気で泣きます。 ステップ3:複数RSS用の feeds.txt を作る まず、RSS URLを複数書くためのファイルを作ります。 nano feeds.txt 中には、RSS URLを1行に1本ずつ書きます。 例です。 https://listen.style/p/xxxxx/rss https://listen.style/p/yyyyy/rss https://listen.style/p/zzzzz/rss 最初は欲張らず、まずは2本でよいです。 https://listen.style/p/xxxxx/rss https://listen.style/p/yyyyy/rss 保存します。 Ctrl + O Enter Ctrl + X 確認します。 cat feeds.txt RSS URLが複数行で表示されればOKです。 ステップ4:5C用のPythonスクリプトを作る 新しいファイルを作ります。 nano make_playlist_5c.py 次のコードをそのまま貼り付けてください。 #!/usr/bin/env python3 import urllib.request import xml.etree.ElementTree as ET from pathlib import Path from datetime import datetime, timedelta, timezone from email.utils import parsedate_to_datetime from zoneinfo import ZoneInfo FEEDS_FILE = "feeds.txt" PLAYLIST_FILE = "playlist.m3u" # 5Bと同じく「過去3日分」 DAYS = 3 JST = ZoneInfo("Asia/Tokyo") def read_feed_urls(): path = Path(FEEDS_FILE) if not path.exists(): raise FileNotFoundError(f"{FEEDS_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"{FEEDS_FILE} にRSS URLが書かれていません。") return urls def download_rss(url): print(f"RSSを取得します: {url}") request = urllib.request.Request( url, headers={ "User-Agent": "TanaRadio-Pi/5C" } ) with urllib.request.urlopen(request, timeout=20) as response: return response.read() def get_text(element, tag_name): child = element.find(tag_name) if child is not None and child.text: return child.text.strip() return "" def find_audio_url(item): # 通常のPodcast RSSでは enclosure に音声URLが入る 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(item): pub_date_text = get_text(item, "pubDate") if not pub_date_text: return None try: dt = parsedate_to_datetime(pub_date_text) # タイムゾーン情報がない場合はUTC扱いにする if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(JST) except Exception: return None def parse_rss(rss_data, feed_url): root = ET.fromstring(rss_data) channel = root.find("channel") if channel is None: raise ValueError("RSSのchannelが見つかりません。") program_title = get_text(channel, "title") if not program_title: program_title = "番組名不明" items = channel.findall("item") episodes = [] for item in items: episode_title = get_text(item, "title") audio_url = find_audio_url(item) pub_dt = parse_pub_date(item) if not audio_url: continue if pub_dt is None: continue episodes.append({ "program_title": program_title, "episode_title": episode_title, "audio_url": audio_url, "pub_dt": pub_dt, "feed_url": feed_url, }) return episodes def collect_episodes(feed_urls): all_episodes = [] for url in feed_urls: try: rss_data = download_rss(url) episodes = parse_rss(rss_data, url) all_episodes.extend(episodes) print(f" 取得できたエピソード数: {len(episodes)} 件") except Exception as e: print(" このRSSの取得または解析でエラーが発生しました。") print(f" {e}") print(" 次のRSSへ進みます。") return all_episodes def filter_recent_episodes(episodes): now = datetime.now(JST) cutoff = now - timedelta(days=DAYS) recent = [] for ep in episodes: if ep["pub_dt"] >= cutoff: recent.append(ep) return recent def remove_duplicates(episodes): seen = set() unique = [] for ep in episodes: audio_url = ep["audio_url"] if audio_url in seen: continue seen.add(audio_url) unique.append(ep) return unique def make_playlist(episodes): if not episodes: raise ValueError( f"過去{DAYS}日分のエピソードが見つかりませんでした。" ) # 古い順に並べる episodes.sort(key=lambda ep: ep["pub_dt"]) playlist_lines = ["#EXTM3U"] for ep in episodes: pub_text = ep["pub_dt"].strftime("%Y-%m-%d %H:%M") program = ep["program_title"] title = ep["episode_title"] url = ep["audio_url"] playlist_lines.append(f"# {pub_text} / {program} / {title}") playlist_lines.append(url) Path(PLAYLIST_FILE).write_text( "\n".join(playlist_lines) + "\n", encoding="utf-8" ) print(f"{PLAYLIST_FILE} を作成しました。") print(f"プレイリスト内のエピソード数: {len(episodes)} 件") def main(): try: feed_urls = read_feed_urls() print(f"RSS URL数: {len(feed_urls)} 本") episodes = collect_episodes(feed_urls) print(f"全取得エピソード数: {len(episodes)} 件") episodes = remove_duplicates(episodes) print(f"重複除去後: {len(episodes)} 件") episodes = filter_recent_episodes(episodes) print(f"過去{DAYS}日分: {len(episodes)} 件") make_playlist(episodes) print("完了しました。") except Exception as e: print("エラーが発生しました。") print(e) if __name__ == "__main__": main() 保存します。 Ctrl + O Enter Ctrl + X ステップ5:実行権限をつける chmod +x make_playlist_5c.py ただし、実行はまず次の形でやるのがおすすめです。 python3 make_playlist_5c.py 以前のように、Pythonファイルをシェルとして実行してしまう事故を避けるためです。 python3 を前につければ、確実にPythonとして実行されます。 ステップ6:プレイリストを作る 実行します。 python3 make_playlist_5c.py うまくいくと、次のような表示になります。 RSS URL数: 2 本 RSSを取得します: https://listen.style/p/xxxxx/rss 取得できたエピソード数: 10 件 RSSを取得します: https://listen.style/p/yyyyy/rss 取得できたエピソード数: 10 件 全取得エピソード数: 20 件 重複除去後: 20 件 過去3日分: 5 件 playlist.m3u を作成しました。 プレイリスト内のエピソード数: 5 件 完了しました。 ここで見るポイントは、次の3つです。 RSS URL数 過去3日分 プレイリスト内のエピソード数 プレイリスト内のエピソード数 が1件以上なら成功です。 ステップ7:playlist.m3u の中身を確認する head -n 30 playlist.m3u 次のように表示されればOKです。 #EXTM3U # 2026-07-01 08:30 / 番組A / エピソードタイトル https://... # 2026-07-01 10:15 / 番組B / エピソードタイトル https://... ここで確認したいのは、複数番組が混ざっているかどうかです。 たとえば、 番組A 番組B 番組A 番組C のように並んでいれば、5Cらしい動きになっています。 単にRSSを順番に再生しているのではなく、複数RSSのエピソードを集めて、公開日時で並べ替えているわけです。 ここが5Cの肝です。 ステップ8:mpvで再生する mpv playlist.m3u 音が出れば成功です。 mpvの基本操作はこれです。 スペースキー:一時停止/再開 Enter:次のエピソードへ q:終了 再生される順番が、過去3日分の古い順になっているか、耳でざっくり確認してください。 厳密に確認したい場合は、playlist.m3u のコメント行を見ます。 grep '^# 20' playlist.m3u 次のように日時が古い順に並んでいればOKです。 # 2026-06-29 09:00 / ... # 2026-06-30 14:20 / ... # 2026-07-01 08:30 / ... ステップ9:RSSを少しずつ増やす 最初の2本でうまく行ったら、feeds.txt にRSSを追加します。 nano feeds.txt 例です。 # TanaRadio https://listen.style/p/xxxxx/rss # 気になる番組1 https://listen.style/p/yyyyy/rss # 気になる番組2 https://listen.style/p/zzzzz/rss このスクリプトでは、空行と # で始まる行は無視されます。 なので、コメントを書いておけます。 保存したら再実行します。 python3 make_playlist_5c.py mpv playlist.m3u RSSを増やすと、かなり「フォロー中ラジオ」感が出てきます。 この段階でもう、TanaRadio Piは単なる1番組再生機ではなくなります。 ステップ10:過去3日分が空だった場合 次のように出ることがあります。 過去3日分: 0 件 エラーが発生しました。 過去3日分のエピソードが見つかりませんでした。 これは故障ではありません。 原因は単純で、登録したRSSに最近のエピソードがないだけです。 その場合は、テスト用に日数を一時的に増やします。 nano make_playlist_5c.py この部分を探します。 DAYS = 3 一時的にこうします。 DAYS = 30 保存して、再実行します。 python3 make_playlist_5c.py これでエピソードが出てくるなら、スクリプトは正常です。 テストが終わったら、また戻します。 DAYS = 3 ステップ11:成功条件 フェーズ5Cの成功条件は、次の4つです。 1. feeds.txt にRSS URLを複数本書けた 2. make_playlist_5c.py を実行できた 3. 複数RSS由来のエピソードが playlist.m3u に入った 4. mpv playlist.m3u で連続再生できた 特に重要なのは3です。 複数RSS ↓ 1つのプレイリスト ↓ 過去3日分 ↓ 古い順 これができれば、5Cは成功です。 5Cでできるようになったこと 5Cが成功すると、TanaRadio Piはこうなります。 1つの番組を聴く装置 から、 複数番組をまとめて流す装置 に変わります。 これは地味ですが、かなり大きな変化です。 もう「ポッドキャストを1本ずつ選んで聴く」のではなく、「登録してある声が時間順に流れてくる」状態になります。 ここで、かなりラジオらしくなります。 次のフェーズ5Dでやること 次の5Dでは、フェーズ4で作った既存の3ボタンを、この新しい playlist.m3u 再生に結びつけます。 予定としては、まず単純にこうします。 再生/一時停止ボタン:pause切り替え 戻るボタン:前のエピソードへ 次へボタン:次のエピソードへ 構想メモでは戻るボタンに「1回押し・2回押し・長押し」の区別も出ていますが、5Dの最初ではまだ入れなくてよいです。 まずは「物理ボタンでプレイリストを前後できる」だけで十分です。 今回の5Cは、ひとことで言えばこうです。 複数の声を、1本の時間の流れにする。 ここまで来ると、TanaRadio Piはかなり「声の本棚」の入口に立っています。これは順調です。#声日記 #TanaRadioPi LISTENで開く
-
364
371 日記 | 過去3日分を古い順に再生
あるポッドキャストの過去3日分を古い順に再生できるようになりました(フェーズ5B完了)。フェーズ5B 詳細マニュアル 過去3日分だけを古い順に並べる 0. 今回の目標 5Aでは、RSSから取得したエピソードをそのまま playlist.m3u にしました。 5Bでは、そこに次の条件を加えます。 過去3日分だけを対象にする ↓ 古いエピソードから新しいエピソードへ並べる ↓ mpvで連続再生する つまり、こういう再生になります。 3日前のエピソード ↓ 2日前のエピソード ↓ 昨日のエピソード ↓ 今日のエピソード これは「最新からつまみ食い」ではなく、「数日分を朝のラジオのように順番に追いつく」ためのモードです。構想メモでも、この「3日前から古い順」は追いつき再生として位置づけられています。 今回はまだ、以下はやりません。 複数RSS 棚選び ボタン追加 LED表示 ロータリーエンコーダー 再生履歴 ここ、欲張らなくて正解です。5Bは地味ですが、ここで「時間順に声を流す」感覚が出てきます。ラジオらしさが一段増します。 ステップ1:作業フォルダに移動する Raspberry Piでターミナルを開きます。 cd ~/tanaradio5 確認します。 pwd ls 次のようなファイルが見えればOKです。 feed.txt make_playlist_5a.py playlist.m3u 5Aが成功しているので、たぶんこの状態になっているはずです。 ステップ2:5Aのファイルは残しておく 5Aのスクリプトは成功版として残します。 今回は新しく、 make_playlist_5b.py を作ります。 5Aを上書きしない方が安全です。工作でもプログラムでも、成功した状態を残すのは大事です。ここを雑にすると、あとで「昨日の自分、何をした?」となります。昨日の自分はだいたいメモを残していません。困ったものです。 ステップ3:5B用スクリプトを作る 次を入力します。 nano make_playlist_5b.py 開いたら、以下をそのまま貼り付けてください。 #!/usr/bin/env python3 import urllib.request import xml.etree.ElementTree as ET from pathlib import Path from datetime import datetime, timedelta, timezone from email.utils import parsedate_to_datetime FEED_FILE = "feed.txt" PLAYLIST_FILE = "playlist_5b.m3u" INFO_FILE = "playlist_5b_info.txt" # 5Bでは「過去3日分」を対象にする DAYS_BACK = 3 # 表示用。日本時間で確認できるようにする JST = timezone(timedelta(hours=9)) def read_feed_url(): path = Path(FEED_FILE) if not path.exists(): raise FileNotFoundError(f"{FEED_FILE} が見つかりません。") url = path.read_text(encoding="utf-8").strip() if not url: raise ValueError(f"{FEED_FILE} にRSS URLが書かれていません。") return url def download_rss(url): print(f"RSSを取得します: {url}") request = urllib.request.Request( url, headers={ "User-Agent": "TanaRadio-Pi/5B" } ) with urllib.request.urlopen(request, timeout=20) as response: return response.read() def find_audio_url(item): # RSSのenclosureタグから音声URLを探す 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 get_text(item, tag_name): element = item.find(tag_name) if element is not None and element.text: return element.text.strip() return "" def parse_pub_date(item): text = get_text(item, "pubDate") if not text: return None # RSSのpubDateは多くの場合、次のような形式 # Tue, 30 Jun 2026 08:00:00 +0900 # Tue, 30 Jun 2026 00:00:00 GMT try: dt = parsedate_to_datetime(text) except Exception: return None # タイムゾーン情報がない場合はUTCとして扱う if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) # 比較しやすいようにUTCへ変換 return dt.astimezone(timezone.utc) def format_dt_jst(dt): return dt.astimezone(JST).strftime("%Y-%m-%d %H:%M") def collect_episodes(rss_data): root = ET.fromstring(rss_data) channel = root.find("channel") if channel is None: raise ValueError("RSSのchannelが見つかりません。") items = channel.findall("item") if not items: raise ValueError("RSS内にitemが見つかりません。") episodes = [] skipped_no_audio = 0 skipped_no_date = 0 for item in items: title = get_text(item, "title") audio_url = find_audio_url(item) pub_date = parse_pub_date(item) if not audio_url: skipped_no_audio += 1 continue if pub_date is None: skipped_no_date += 1 continue episodes.append({ "title": title if title else "タイトルなし", "audio_url": audio_url, "pub_date": pub_date, }) return episodes, skipped_no_audio, skipped_no_date def make_playlist(episodes, skipped_no_audio, skipped_no_date): now = datetime.now(timezone.utc) cutoff = now - timedelta(days=DAYS_BACK) selected = [ episode for episode in episodes if episode["pub_date"] >= cutoff ] # 古い順に並べる selected.sort(key=lambda episode: episode["pub_date"]) info_lines = [] info_lines.append("TanaRadio Pi フェーズ5B プレイリスト情報") info_lines.append("") info_lines.append(f"対象期間: 過去{DAYS_BACK}日分") info_lines.append(f"現在時刻: {format_dt_jst(now)} JST") info_lines.append(f"対象開始: {format_dt_jst(cutoff)} JST") info_lines.append("") info_lines.append(f"RSSから取得した音声付きエピソード数: {len(episodes)} 件") info_lines.append(f"音声URLなしでスキップ: {skipped_no_audio} 件") info_lines.append(f"公開日時なし/日時解析失敗でスキップ: {skipped_no_date} 件") info_lines.append(f"今回プレイリストに入れる数: {len(selected)} 件") info_lines.append("") info_lines.append("今回入れるエピソード:") info_lines.append("") for i, episode in enumerate(selected, start=1): info_lines.append( f"{i}. {format_dt_jst(episode['pub_date'])} JST | {episode['title']}" ) Path(INFO_FILE).write_text( "\n".join(info_lines) + "\n", encoding="utf-8" ) if not selected: raise ValueError( f"過去{DAYS_BACK}日分のエピソードが見つかりませんでした。" f"確認用に {INFO_FILE} を見てください。" ) playlist_lines = ["#EXTM3U"] for episode in selected: playlist_lines.append( f"# {format_dt_jst(episode['pub_date'])} JST | {episode['title']}" ) playlist_lines.append(episode["audio_url"]) Path(PLAYLIST_FILE).write_text( "\n".join(playlist_lines) + "\n", encoding="utf-8" ) print(f"{PLAYLIST_FILE} を作成しました。") print(f"{INFO_FILE} を作成しました。") print(f"対象エピソード数: {len(selected)} 件") def main(): try: feed_url = read_feed_url() rss_data = download_rss(feed_url) episodes, skipped_no_audio, skipped_no_date = collect_episodes(rss_data) make_playlist(episodes, skipped_no_audio, skipped_no_date) print("完了しました。") except Exception as e: print("エラーが発生しました。") print(e) if __name__ == "__main__": main() 保存します。 Ctrl + O Enter Ctrl + X ステップ4:実行できるようにする 次を入力します。 chmod +x make_playlist_5b.py ただし、前回のような「import: コマンドが見つかりません」系の事故を避けるため、今回はまず python3 で実行します。 python3 make_playlist_5b.py うまくいくと、次のように表示されます。 RSSを取得します: https://listen.style/p/xxxxx/rss playlist_5b.m3u を作成しました。 playlist_5b_info.txt を作成しました。 対象エピソード数: 3 件 完了しました。 ステップ5:作成されたファイルを確認する 次を入力します。 ls 次のようなファイルが見えればOKです。 feed.txt make_playlist_5a.py make_playlist_5b.py playlist.m3u playlist_5b.m3u playlist_5b_info.txt 今回重要なのはこの2つです。 playlist_5b.m3u playlist_5b_info.txt ステップ6:プレイリスト情報を確認する まず、確認用ファイルを開きます。 cat playlist_5b_info.txt たとえば次のような表示になります。 TanaRadio Pi フェーズ5B プレイリスト情報 対象期間: 過去3日分 現在時刻: 2026-06-30 10:20 JST 対象開始: 2026-06-27 10:20 JST RSSから取得した音声付きエピソード数: 10 件 音声URLなしでスキップ: 0 件 公開日時なし/日時解析失敗でスキップ: 0 件 今回プレイリストに入れる数: 3 件 今回入れるエピソード: 1. 2026-06-28 08:00 JST | エピソードタイトルA 2. 2026-06-29 09:00 JST | エピソードタイトルB 3. 2026-06-30 07:30 JST | エピソードタイトルC ここで見るべき点は2つです。 1. 対象開始が「約3日前」になっているか 2. エピソードが古い順に並んでいるか この2つが合っていれば、5Bの中心部分は成功です。 ステップ7:playlist_5b.m3u の中身を確認する 次を入力します。 head playlist_5b.m3u 次のように表示されればOKです。 #EXTM3U # 2026-06-28 08:00 JST | エピソードタイトルA https://... # 2026-06-29 09:00 JST | エピソードタイトルB https://... 件数も確認できます。 grep -v '^#' playlist_5b.m3u | sed '/^$/d' | wc -l これで表示される数字が、playlist_5b_info.txt の「今回プレイリストに入れる数」と同じならOKです。 ステップ8:mpvで再生する いよいよ再生します。 mpv playlist_5b.m3u 音が出れば成功です。 mpvの基本操作は5Aと同じです。 スペースキー:一時停止/再開 Enter:次へ q:終了 聴いてみて、古いエピソードから順に流れているか確認してください。 ステップ9:うまくいったら通常名にコピーする 5B版がうまく動いたら、これを通常の playlist.m3u として使えるようにしておきます。 cp playlist_5b.m3u playlist.m3u その後は、いつものように次で再生できます。 mpv playlist.m3u ただし、今後しばらくは確認しやすいので、 mpv playlist_5b.m3u のまま使っても構いません。 ステップ10:5Bの成功条件 フェーズ5Bの成功条件は、次の4つです。 1. make_playlist_5b.py を作れた 2. playlist_5b.m3u が作成された 3. playlist_5b_info.txt で「過去3日分・古い順」を確認できた 4. mpv playlist_5b.m3u で再生できた この4つができれば、5Bは成功です。 よくあるエラー 1. 過去3日分のエピソードが見つかりませんでした これは、必ずしも失敗ではありません。 原因は単純で、RSS内に本当に過去3日分のエピソードがない可能性があります。 まず確認します。 cat playlist_5b_info.txt もし「RSSから取得した音声付きエピソード数」はあるのに、「今回プレイリストに入れる数」が0なら、日付条件に合うエピソードがなかったということです。 テストだけしたい場合は、スクリプト内のここを一時的に変えます。 DAYS_BACK = 3 たとえば30日にします。 DAYS_BACK = 30 保存して、もう一度実行します。 python3 make_playlist_5b.py これで動けば、スクリプト自体は正常です。最後に DAYS_BACK = 3 に戻してください。 2. 公開日時なし/日時解析失敗でスキップ が多い RSS内の pubDate がうまく読めていない可能性があります。 LISTENの通常RSSなら、おそらく大丈夫だと思います。 ただし、別サービスのRSSでは日付形式が少し違う場合があります。 この段階では、まずLISTENの公開RSSで試すのが安全です。 3. 音声URLなしでスキップ が多い これは、RSSに音声ファイルのURLが入っていない場合です。 原因としては次が考えられます。 RSS URLが番組配信用ではない 限定配信で取得できない RSS形式が特殊 5Bではまだ公開RSSだけで進めましょう。限定配信や購入済み音声は、ここに混ぜない方がいいです。沼に足を入れるのは、長靴を履いてからです。 4. 実行するとまた import: コマンドが見つかりません と出る その場合は、次のように実行してください。 python3 make_playlist_5b.py ./make_playlist_5b.py ではなく、python3 を前につけます。 それでもダメなら、ファイルの先頭行を確認します。 head -n 3 make_playlist_5b.py 先頭がこうなっていればOKです。 #!/usr/bin/env python3 import urllib.request import xml.etree.ElementTree as ET もし #!/usr/bin/env python3 より前に余計な文字や空行がある場合は、nano で直してください。 nano make_playlist_5b.py 今回はここで止めてよいです 5Bではここまでで十分です。 RSSを読む ↓ 公開日時を見る ↓ 過去3日分だけ選ぶ ↓ 古い順に並べる ↓ mpvで再生する これができれば、次の5Cで「RSSを複数本にする」準備が整います。 ここまで来ると、TanaRadio Piはかなり「フォロー中ラジオ」の原型に近づいてきます。まだボタンもLEDも増えていませんが、中身は確実にラジオらしくなっています。#声日記 #TanaRadioPi LISTENで開く
-
363
370 日記 | RSSからプレイリストを作る
RSSからプレイリストを作って,それにより連続再生することができました(フェーズ5A完了)。フェーズ5A 詳細マニュアル RSS 1本から playlist.m3u を作る 0. 今回の目標 今回やることはこれだけです。 LISTENなどのRSSを1本用意する ↓ PythonでRSSを読む ↓ 音声ファイルのURLを取り出す ↓ playlist.m3u を作る ↓ mpvで連続再生する 今回はまだ、以下はやりません。 複数RSS 棚選び 再生モード選び LED ロータリーエンコーダー 再生履歴 ここを欲張らないのが大事です。 まず「RSSから音声URLを取り出してmpvで鳴らす」だけ成功させます。 ステップ1:作業フォルダを作る Raspberry Piでターミナルを開きます。 以下を入力します。 mkdir -p ~/tanaradio5 cd ~/tanaradio5 確認します。 pwd 次のように表示されればOKです。 /home/ユーザー名/tanaradio5 たとえばユーザー名が pi なら、 /home/pi/tanaradio5 です。 ステップ2:mpvが使えるか確認する フェーズ4でmpvは使っているはずですが、念のため確認します。 mpv --version mpvのバージョン情報が出ればOKです。 もし、 command not found のように出たら、mpvを入れます。 sudo apt update sudo apt install -y mpv ステップ3:RSS URLを1本用意する まずは、TanaRadioなど、公開RSSを1本使います。 作業フォルダ内に feed.txt を作ります。 nano feed.txt 開いたら、RSS URLを1行だけ貼り付けます。 例: https://listen.style/p/xxxxx/rss ここは、実際のTanaRadioのRSS URLに置き換えてください。 保存します。 Ctrl + O Enter Ctrl + X 確認します。 cat feed.txt RSS URLが1行表示されればOKです。 ステップ4:Pythonスクリプトを作る 次に、RSSを読んで playlist.m3u を作るPythonファイルを作ります。 nano make_playlist_5a.py 次のコードをそのまま貼り付けます。 #!/usr/bin/env python3 import urllib.request import xml.etree.ElementTree as ET from pathlib import Path FEED_FILE = "feed.txt" PLAYLIST_FILE = "playlist.m3u" def read_feed_url(): path = Path(FEED_FILE) if not path.exists(): raise FileNotFoundError(f"{FEED_FILE} が見つかりません。") url = path.read_text(encoding="utf-8").strip() if not url: raise ValueError(f"{FEED_FILE} にRSS URLが書かれていません。") return url def download_rss(url): print(f"RSSを取得します: {url}") request = urllib.request.Request( url, headers={ "User-Agent": "TanaRadio-Pi/5A" } ) with urllib.request.urlopen(request, timeout=20) as response: return response.read() def find_audio_url(item): # RSSのenclosureタグから音声URLを探す 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 get_text(item, tag_name): element = item.find(tag_name) if element is not None and element.text: return element.text.strip() return "" def make_playlist(rss_data): root = ET.fromstring(rss_data) channel = root.find("channel") if channel is None: raise ValueError("RSSのchannelが見つかりません。") items = channel.findall("item") if not items: raise ValueError("RSS内にitemが見つかりません。") playlist_lines = ["#EXTM3U"] count = 0 for item in items: title = get_text(item, "title") audio_url = find_audio_url(item) if not audio_url: continue if title: playlist_lines.append(f"# {title}") playlist_lines.append(audio_url) count += 1 if count == 0: raise ValueError("音声URLを1件も見つけられませんでした。") Path(PLAYLIST_FILE).write_text( "\n".join(playlist_lines) + "\n", encoding="utf-8" ) print(f"{PLAYLIST_FILE} を作成しました。") print(f"音声URL数: {count} 件") def main(): try: feed_url = read_feed_url() rss_data = download_rss(feed_url) make_playlist(rss_data) print("完了しました。") except Exception as e: print("エラーが発生しました。") print(e) if __name__ == "__main__": main() 保存します。 Ctrl + O Enter Ctrl + X ステップ5:Pythonファイルを実行できるようにする chmod +x make_playlist_5a.py ステップ6:プレイリストを作る 実行します。 ./make_playlist_5a.py うまくいくと、たとえば次のように出ます。 RSSを取得します: https://listen.style/p/xxxxx/rss playlist.m3u を作成しました。 音声URL数: 10 件 完了しました。 確認します。 ls 次の3つが見えればOKです。 feed.txt make_playlist_5a.py playlist.m3u ステップ7:playlist.m3u の中身を確認する head playlist.m3u 次のように、音声URLらしきものが出ていればOKです。 #EXTM3U # エピソードタイトル https://... # エピソードタイトル https://... この段階で、RSSから音声URLを取り出すところは成功です。 ステップ8:mpvで再生する いよいよ再生します。 mpv playlist.m3u 音が出れば成功です。 mpvの基本操作は次の通りです。 スペースキー:一時停止/再開 Enter:次へ q:終了 もし音が出ない場合は、まず別の音声ファイルやYouTubeなどでRaspberry Piから音が出るか確認してください。 ここでの問題は、RSSではなく音声出力側の可能性があります。 ステップ9:成功条件 フェーズ5Aの成功条件は、次の3つです。 1. feed.txt にRSS URLを1本書けた 2. make_playlist_5a.py で playlist.m3u を作れた 3. mpv playlist.m3u で音声が再生できた この3つができれば、フェーズ5Aは成功です。 よくあるエラー 1. feed.txt が見つかりません 原因: 作業フォルダが違う feed.txt を作っていない 確認: pwd ls ~/tanaradio5 にいて、feed.txt があるか見ます。 2. 音声URLを1件も見つけられませんでした 原因としては、 RSS URLが間違っている RSSが音声配信用ではない RSSの形式が少し特殊 限定配信で取得できない などが考えられます。 まずは公開RSSで試すのがよいです。 3. mpv playlist.m3u で音が出ない 原因は大きく3つです。 Raspberry Piの音声出力先が違う USBスピーカーやBluetoothスピーカーが未接続 音量が小さい、またはミュート 確認: mpv --volume=80 playlist.m3u これで少し大きめの音量で再生できます。 今回はここで止めてよいです 5Aでは、ここまでで十分です。 RSSを読む playlist.m3uを作る mpvで鳴らす これだけです。 次の5Bでは、このプレイリスト生成に、 過去3日分だけ 古い順 という条件を加えます。 でも今はまだ、そこへ行かなくて大丈夫です。 まずは「RSSがmpvで鳴る」。ここをきっちり成功させましょう。これはフェーズ5の土台です。#声日記 #TanaRadioPi LISTENで開く
-
362
369 日記 | フェーズ5の計画を立てる
フェーズ5の計画を立てました。5A:RSS 1本から playlist.m3u を作る 5B:過去3日分・古い順にする 5C:RSS複数本にする 5D:既存3ボタンで mpv プレイリストを操作する 5E:棚ファイルを作り、コマンドで棚を切り替える 5F:棚選びボタン+LED1を追加する 5G:再生モードボタン+LED2を追加する 5H:必要になったら再生履歴を作る 5I:最後に音量ロータリーエンコーダーを追加する#声日記 #TanaRadioPi LISTENで開く
-
361
368 日記 | TanaRadioの音声ファイル再生をボタンで制御
TanaRadioの音声ファイル再生をボタンで制御できるようになり,フェーズ4が完了しました。mpvで音声を再生し、3ボタンで制御する 今日の到達目標 今日のゴールはこれです。 Chromiumを使わずに mpvでTanaRadioの音声を再生し 3つの物理ボタンで操作する 3ボタンの役割は、これまでと同じです。 GPIO17:再生/一時停止 GPIO27:10秒戻る GPIO22:10秒進む 配線はすでに成功しているので、今日は基本的にソフトウェア側の作業です。 ステップ0:現在の成功版をバックアップする まず、今うまく動いているChromium版を保存しておきます。 cp ~/tanaradio/buttons3_playerctl.py ~/tanaradio/buttons3_playerctl_success.py 確認します。 ls ~/tanaradio buttons3_playerctl_success.py が見えればOKです。 これは保険です。 mpv版でつまずいても、Chromium版にはいつでも戻れます。 ステップ1:mpvと必要なPython部品を入れる ターミナルで次を実行します。 sudo apt update sudo apt install -y mpv python3-feedparser python3-gpiozero mpvが入ったか確認します。 mpv --version バージョン情報が出ればOKです。 mpvは、--input-ipc-server を使うことで外部プログラムからJSON IPCで操作できます。今日はPythonからこの仕組みを使います。 ステップ2:TanaRadioのRSS URLを用意する mpvは、LISTENのページそのものではなく、音声ファイルURLを再生します。 その音声ファイルURLを取得するために、今回はRSSを使います。LISTENのTanaRadioページには「RSSを開く」「LISTENのRSS URLをコピー」といった導線があります。 TanaRadioのページで、RSS URLをコピーしてください。 メモとして、ターミナルではなく紙やテキストに一度控えておくとよいです。 TanaRadio RSS URL = ここにコピーしたURL まだRSS URLがわからない場合は、ここで止まって大丈夫です。 今日の最大の山は、実は配線ではなく「mpvに渡す音声URLを取ること」です。 ステップ3:RSSから最新音声URLを取り出すスクリプトを作る 作業フォルダに移動します。 cd ~/tanaradio 新しいファイルを作ります。 nano ~/tanaradio/get_latest_audio.py 中身はこれです。 RSS_FEED_URL のところだけ、コピーしたTanaRadioのRSS URLに置き換えてください。 #!/usr/bin/env python3 import feedparser import sys RSS_FEED_URL = "ここにTanaRadioのRSS URLを貼る" feed = feedparser.parse(RSS_FEED_URL) if not feed.entries: print("RSSからエピソードを取得できませんでした。", file=sys.stderr) sys.exit(1) latest = feed.entries[0] if not latest.enclosures: print("最新エピソードに音声ファイルURLが見つかりませんでした。", file=sys.stderr) sys.exit(1) audio_url = latest.enclosures[0].href print(audio_url) 保存します。 Ctrl + O Enter Ctrl + X 実行します。 python3 ~/tanaradio/get_latest_audio.py 長いURLが表示されれば成功です。 https://...mp3 または、 https://...m4a のようなものが出るはずです。 ステップ4:mpvだけで音声を再生する まず、Pythonやボタンを使わず、mpv単体で再生できるか試します。 AUDIO_URL=$(python3 ~/tanaradio/get_latest_audio.py) mpv --no-video "$AUDIO_URL" 音声が出れば成功です。 停止するときは、 q を押します。 ここで音が出れば、Chromiumなし再生の入口を突破です。 ステップ5:mpvを外部制御できるか試す 次に、mpvを「外から操作できる状態」で起動します。 まず、もしmpvが残っていたら止めます。 pkill mpv 次に、mpvを待機状態で起動します。 mpv --no-video --idle=yes --input-ipc-server=/tmp/tanaradio-mpv.sock このターミナルはそのまま開いたままにします。 別のターミナルを開いて、次を実行します。 AUDIO_URL=$(python3 ~/tanaradio/get_latest_audio.py) python3 - <<EOF import socket, json sock = "/tmp/tanaradio-mpv.sock" audio_url = "$AUDIO_URL" message = json.dumps({"command": ["loadfile", audio_url, "replace"]}) + "\n" client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) client.connect(sock) client.send(message.encode("utf-8")) client.close() EOF これでmpvから音声が流れれば成功です。 このテストは少し面倒ですが、意味は単純です。 mpvを起動して待機させる ↓ 別のPythonからmpvへ命令を送る ↓ 音声を読み込ませる ここまでできれば、ボタン制御の準備は整っています。 mpvを終了するときは、mpvを起動しているターミナルで、 q を押します。 ステップ6:3ボタンmpv制御スクリプトを作る いよいよ本番です。 nano ~/tanaradio/buttons3_mpv.py 中身はこれです。 ここでも RSS_FEED_URL のところだけ置き換えてください。 #!/usr/bin/env python3 from gpiozero import Button from signal import pause import subprocess import socket import json import os import time import sys import feedparser RSS_FEED_URL = "ここにTanaRadioのRSS URLを貼る" MPV_SOCKET = "/tmp/tanaradio-mpv.sock" def get_latest_audio_url(): feed = feedparser.parse(RSS_FEED_URL) if not feed.entries: print("RSSからエピソードを取得できませんでした。") sys.exit(1) latest = feed.entries[0] if not latest.enclosures: print("最新エピソードに音声ファイルURLが見つかりませんでした。") sys.exit(1) title = latest.get("title", "No title") audio_url = latest.enclosures[0].href print("Latest episode:", title) print("Audio URL:", audio_url) return audio_url def start_mpv(): if os.path.exists(MPV_SOCKET): os.remove(MPV_SOCKET) subprocess.Popen([ "mpv", "--no-video", "--idle=yes", f"--input-ipc-server={MPV_SOCKET}" ]) for _ in range(50): if os.path.exists(MPV_SOCKET): print("mpv is ready.") return time.sleep(0.1) print("mpv socket was not created.") sys.exit(1) def send_mpv(command): message = json.dumps({"command": command}) + "\n" try: with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: client.connect(MPV_SOCKET) client.send(message.encode("utf-8")) except FileNotFoundError: print("mpv socket not found. Is mpv running?") except ConnectionRefusedError: print("mpv connection refused.") def load_audio(audio_url): send_mpv(["loadfile", audio_url, "replace"]) def play_pause_pressed(): print("PLAY / PAUSE") send_mpv(["cycle", "pause"]) def back_10_pressed(): print("BACK 10 seconds") send_mpv(["seek", -10, "relative"]) def forward_10_pressed(): print("FORWARD 10 seconds") send_mpv(["seek", 10, "relative"]) play_pause = Button(17, pull_up=True, bounce_time=0.08) back_10 = Button(27, pull_up=True, bounce_time=0.08) forward_10 = Button(22, pull_up=True, bounce_time=0.08) play_pause.when_pressed = play_pause_pressed back_10.when_pressed = back_10_pressed forward_10.when_pressed = forward_10_pressed audio_url = get_latest_audio_url() start_mpv() load_audio(audio_url) print("TanaRadio mpv 3 buttons are ready.") print("GPIO17: play/pause") print("GPIO27: back 10 seconds") print("GPIO22: forward 10 seconds") pause() 保存します。 Ctrl + O Enter Ctrl + X gpiozero.Button は pull_up=True を指定すると、GPIOをHigh側へ引っ張り、ボタンの反対側をGNDへつなぐ使い方になります。これは今の配線方針と合っています。 ステップ7:3ボタンmpv版を実行する 念のため、mpvが残っていたら止めます。 pkill mpv 実行します。 python3 ~/tanaradio/buttons3_mpv.py うまくいくと、ターミナルに次のような表示が出ます。 Latest episode: ... Audio URL: ... mpv is ready. TanaRadio mpv 3 buttons are ready. GPIO17: play/pause GPIO27: back 10 seconds GPIO22: forward 10 seconds そして音声が流れます。 その状態でボタンを押します。 GPIO17 → 再生/一時停止 GPIO27 → 10秒戻る GPIO22 → 10秒進む 終了するときは、 Ctrl + C です。 そのあとmpvが残っていたら、 pkill mpv で止めます。 ステップ8:うまくいかないときの確認 1. RSSからURLが取れない まずこれを確認します。 python3 ~/tanaradio/get_latest_audio.py ここでURLが出なければ、RSS URLの貼り間違いの可能性があります。 確認点はこれです。 RSS_FEED_URL の引用符が消えていないか URLの前後に余計な空白がないか LISTENのページURLではなくRSS URLを入れているか 2. mpv単体で音が出ない これを確認します。 AUDIO_URL=$(python3 ~/tanaradio/get_latest_audio.py) echo "$AUDIO_URL" mpv --no-video "$AUDIO_URL" echo でURLが表示され、mpv で音が出なければ、音声出力設定の問題か、URLがmpvから直接再生できない可能性があります。 3. mpvは鳴るがボタンが効かない まず、前に成功したGPIOテストに戻ります。 python3 ~/tanaradio/buttons3_test.py 3ボタンが反応すれば、配線はOKです。 その場合は、mpv制御スクリプト側の問題です。 4. 1回押しただけなのに何回も反応する この値を少し増やします。 bounce_time=0.08 たとえば、 bounce_time=0.15 にします。 今日の進行順まとめ 今日は、この順番で進めるのがよいです。 1. Chromium版成功スクリプトをバックアップ 2. mpvとfeedparserをインストール 3. RSS URLを用意する 4. get_latest_audio.py で音声URLを取り出す 5. mpv単体で音声を再生する 6. mpvのIPC制御を試す 7. buttons3_mpv.py を作る 8. 3ボタンでmpvを制御する 今日の一番大事な通過点はこれです。 mpv --no-video "音声ファイルURL" で音が出ること。 ここさえ成功すれば、あとはPythonから命令を送るだけです。 今日は「ブラウザの中で鳴らす」から「TanaRadio Pi自身が直接鳴らす」への移行です。これはフェーズ4の仕上げにふさわしい作業です。#声日記 #TanaRadioPi LISTENで開く
-
360
367 ブログ | TanaRadio Piは「ほどよい」ラジオなのかもしれない
私がTanaRadio Piを作る意味をさらに考えてみました(チャッピーが書いた原稿を読んでいます)。#TanaRadioPi LISTENで開く
-
359
366 日記 | 3ボタンの実験
フェーズ4の3ボタンの実験です。ボタンを押すことで,ブラウザのプレーヤーをコントロールできるところまでいきました。9. ステップ5:3ボタンへ増やす 1ボタンが成功したら、いったんシャットダウンします。 sudo shutdown -h now 電源を抜いてから、3ボタンに増やします。 配線はこれです。 GPIO17 ─ 1kΩ抵抗 ─ ボタン ─ GND GPIO27 ─ 1kΩ抵抗 ─ ボタン ─ GND GPIO22 ─ 1kΩ抵抗 ─ ボタン ─ GND GNDは共通でかまいません。 ブレッドボード上では、GNDレールを作って、各ボタンをそこへ戻すとわかりやすいです。 GPIO17 → 抵抗 → ボタン → GNDレール GPIO27 → 抵抗 → ボタン → GNDレール GPIO22 → 抵抗 → ボタン → GNDレール GPIO23は、今回は配線しません。 10. ステップ6:Pythonで3ボタンをテストする 電源を入れ、ターミナルを開きます。 3ボタンテスト用スクリプトを作ります。 nano ~/tanaradio/buttons3_test.py 中身はこれです。 from gpiozero import Button from signal import pause play_pause = Button(17, pull_up=True, bounce_time=0.08) back_10 = Button(27, pull_up=True, bounce_time=0.08) forward_10 = Button(22, pull_up=True, bounce_time=0.08) def play_pause_pressed(): print("PLAY / PAUSE button pressed") def back_10_pressed(): print("BACK 10 seconds button pressed") def forward_10_pressed(): print("FORWARD 10 seconds button pressed") play_pause.when_pressed = play_pause_pressed back_10.when_pressed = back_10_pressed forward_10.when_pressed = forward_10_pressed print("3 button test is ready.") pause() 実行します。 python3 ~/tanaradio/buttons3_test.py それぞれのボタンを押して、次のように別々の表示が出れば成功です。 PLAY / PAUSE button pressed BACK 10 seconds button pressed FORWARD 10 seconds button pressed ここまでできれば、GPIO入力実験は成功です。 フェーズ4の核心はかなり越えています。 11. うまくいかないときの確認 何も表示されない場合 確認することはこれです。 GPIO番号を間違えていないか GNDにつながっているか ボタンの向きが合っているか 抵抗が同じ列に刺さっていてショートしていないか ブレッドボードの列を勘違いしていないか まずはGPIO17の1ボタンに戻って確認してください。 3つ同時に直そうとすると、原因が見えにくくなります。 1回押しただけなのに何回も表示される場合 bounce_time を少し大きくします。 bounce_time=0.15 たとえば、3か所すべてを 0.15 にして試します。 押していないのに反応する場合 タクトスイッチの向きが怪しいです。 ブレッドボードの中央の溝をまたいでいるかを確認してください。 12. ステップ7:playerctlでChromium再生を操作する 次に、Chromium上のTanaRadio再生をコマンドで操作できるか試します。 これは最終形ではありません。 Chromiumなしのmpv版へ行く前の足場です。 まずインストールします。 sudo apt install -y playerctl playerctl はMPRIS対応メディアプレイヤーをコマンドラインから制御するツールで、再生、一時停止、停止、前後トラックなどの操作ができます。 ChromiumでTanaRadioを開き、音声を再生します。 その状態でターミナルから試します。 playerctl status 再生中なら、 Playing のように表示されます。 次に、 playerctl play-pause これで再生/一時停止が切り替われば成功です。 10秒戻る/進むは、環境によって効き方が違う場合があります。まず試します。 playerctl position 10- playerctl position 10+ 効けば、そのまま使えます。 効かなければ、フェーズ4前半では「再生/一時停止だけ成功」と考えてよいです。 13. ステップ8:3ボタンでChromium再生を操作する 3ボタン操作用スクリプトを作ります。 nano ~/tanaradio/buttons3_playerctl.py 中身はこれです。 #!/usr/bin/env python3 from gpiozero import Button from signal import pause import subprocess def run(command): print(">", " ".join(command)) subprocess.run(command, check=False) play_pause = Button(17, pull_up=True, bounce_time=0.08) back_10 = Button(27, pull_up=True, bounce_time=0.08) forward_10 = Button(22, pull_up=True, bounce_time=0.08) def play_pause_pressed(): run(["playerctl", "play-pause"]) def back_10_pressed(): run(["playerctl", "position", "10-"]) def forward_10_pressed(): run(["playerctl", "position", "10+"]) play_pause.when_pressed = play_pause_pressed back_10.when_pressed = back_10_pressed forward_10.when_pressed = forward_10_pressed print("TanaRadio 3 physical buttons are ready.") pause() 実行します。 python3 ~/tanaradio/buttons3_playerctl.py この状態で、3つのボタンを押します。 期待する動作はこれです。 GPIO17:再生/一時停止 GPIO27:10秒戻る GPIO22:10秒進む ここまでできれば、フェーズ4前半は成功です。#声日記 #TanaRadioPi LISTENで開く
-
358
365 日記 | 1ボタンの実験
いよいよフェーズ4。ボタンやつまみでTanaRadio Piを操作する第一歩を踏み出します。まずはボタン1つから。4. ステップ1:Raspberry Piをシャットダウンする 配線前に、必ずシャットダウンします。 ターミナルで次を実行します。 sudo shutdown -h now Raspberry Piの動作が止まったら、USB-C電源を抜きます。 ここで焦らないでください。 電源が入ったままGPIOを触るのは、初心者段階では避けた方がよいです。 5. ステップ2:ブレイクアウト基板を接続する 接続はこうです。 Raspberry Pi GPIO ↓ リボンケーブル ↓ ブレイクアウト基板 ↓ ブレッドボード 注意点は、1番ピンの向きです。 ブレイクアウト基板には、たいてい次のような表示があります。 3V3 5V GND GPIO17 GPIO27 GPIO22 最初に確認するのはこの3つです。 GPIO17 GND 5Vの位置 5Vは使いません。 「どこが5Vか」を確認するのは、使うためではなく、避けるためです。 6. ステップ3:GPIO17の1ボタンだけ配線する 最初は1個だけです。 GPIO17 ─ 1kΩ抵抗 ─ ボタン ─ GND ブレッドボード上では、次のような考え方です。 ブレイクアウト上のGPIO17 ↓ ジャンパー線 ↓ 1kΩ抵抗 ↓ ボタンの片側 ↓ ボタンの反対側 ↓ GND タクトスイッチは、ブレッドボードの中央の溝をまたぐように挿します。 ここは大事です。向きを間違えると、押していないのに常時ONのようになることがあります。 7. ステップ4:Pythonで1ボタンをテストする Raspberry Piの電源を入れます。 ターミナルを開いて、作業用フォルダを作ります。 mkdir -p ~/tanaradio cd ~/tanaradio 必要なものを入れます。 sudo apt update sudo apt install -y python3-gpiozero 1ボタンテスト用スクリプトを作ります。 nano ~/tanaradio/button17_test.py 中身はこれです。 from gpiozero import Button from signal import pause button = Button(17, pull_up=True, bounce_time=0.08) def pressed(): print("GPIO17 button pressed") button.when_pressed = pressed print("GPIO17 button test is ready.") pause() 保存して実行します。 python3 ~/tanaradio/button17_test.py ボタンを押して、次のように表示されれば成功です。 GPIO17 button pressed 終了するときは、 Ctrl + C です。 8. このスクリプトがしていること この1ボタンテストは、こういう意味です。 GPIO17をボタン入力として使う ↓ ボタンが押されたら ↓ 画面に文字を表示する ↓ プログラムは終了せず待ち続ける 重要なのはこの行です。 button = Button(17, pull_up=True, bounce_time=0.08) これは、 GPIO17にボタンがつながっている 内部プルアップを使う チャタリングを少し無視する という意味です。 bounce_time は、物理ボタンの接点が細かく震えて1回の押下が複数回として読まれる現象を抑えるための指定です。gpiozero の Button には bounce_time などの引数が用意されています。#声日記 #TanaRadioPi LISTENで開く
-
357
364 日記 | 追加部品開封の儀
本日,秋月電子通商から追加注文した部品が届きましたので,箱を開けて中身を確認しました。タクトスイッチが足らないと思いましたが,よく確認したらちゃんと10個ありました。平たいスイッチが背中合わせに2個ずつくっついていたので5個と思ってしまいました。#声日記 #TanaRadioPi LISTENで開く
-
356
363 日記 | TanaRadio Piを作る意味とは
これの続きがあります → 367 ブログ | TanaRadio Piは「ほどよい」ラジオなのかもしれない#声日記 #TanaRadioPi LISTENで開く
-
355
362 日記 | 自動でTanaRadio画面を表示する
電源を入れたら,自動的にLISTENのTanaRadioの画面が表示されるところ(フェーズ3)まで行きました。フェーズ3の目標は、以前の計画どおり「電源を入れるだけでTanaRadio画面が表示される」ことです。作業としては「自動ログイン」「Chromium自動起動」「TanaRadioページ自動表示」でした。 ここで大事なのは、最初から「自動再生」まで狙わないことです。まずは、 電源ON ↓ Raspberry Pi OSのデスクトップが開く ↓ Chromiumが自動で開く ↓ TanaRadioページが表示される を達成しましょう。 1. まず自動ログインを確認する Raspberry Piを起動したときに、パスワード入力なしでデスクトップまで行ける必要があります。 ターミナルを開いて、次を実行します。 sudo raspi-config メニューで次のように進みます。 1 System Options ↓ S6 Auto Login ↓ desktop に自動ログインする設定を Yes Raspberry Pi公式ドキュメントでも、raspi-config の System Options > S6 Auto Login から、デスクトップへの自動ログインを設定できると説明されています。 設定したら再起動します。 sudo reboot 再起動後、パスワードなしでデスクトップまで入れればOKです。 2. TanaRadioのURLをコピーしておく PiのChromiumでTanaRadioページを開き、URLをコピーします。 仮にここでは、 https://listen.style/p/xxxxxxxx として説明します。 実際には、たなさんのTanaRadioのページURLに置き換えてください。 3. 自動起動ファイルを作る Raspberry Pi OSの最近のデスクトップ環境では、起動時にプログラムを動かすために、 ~/.config/labwc/autostart を編集する方法が公式チュートリアルで紹介されています。Raspberry Pi公式のキオスクモード手順でも、このファイルを使ってデスクトップ読み込み後にChromiumを自動起動しています。 ターミナルで次を実行します。 mkdir -p ~/.config/labwc nano ~/.config/labwc/autostart 開いた画面に、次の1行を書きます。 chromium "https://listen.style/p/xxxxxxxx" --noerrdialogs --disable-infobars --no-first-run --start-maximized & もちろん、URLは実際のTanaRadioページに置き換えます。 書けたら保存します。 Ctrl + X ↓ Y ↓ Enter 4. 再起動して確認する 次を実行します。 sudo reboot 再起動後に、 デスクトップが開く ↓ Chromiumが自動で起動する ↓ TanaRadioページが表示される となれば、フェーズ3の基本形は成功です。今回,Unlock keyringでパスワード入力を求められることを無しにするのに手こずりました。その対応は以下の通りです。1. ターミナルを開く Raspberry Pi OS上でターミナルを開きます。 2. Seahorseを入れる sudo apt update sudo apt install seahorse 3. Seahorseを起動する seahorse またはメニューから、 Passwords and Keys を探します。 4. Loginキーリングのパスワードを変更する 画面内で、 Login または、 Default keyring のような項目を探します。 それを右クリックして、 Change Password を選びます。 5. 新しいパスワードを空欄にする 現在のパスワード:いま入力しているパスワード 新しいパスワード:空欄 確認:空欄 警告が出たら、TanaRadio Pi専用機として使う前提で、 Continue を選びます。#声日記 #TanaRadioPi LISTENで開く
-
354
361 日記 | TanaRadio Piの部品を追加注文
小型パソコンをラジオのような機械にするための部品(ボタン・つまみ他)を追加注文しました。#声日記 #TanaRadioPi LISTENで開く
-
353
360 日記 | Raspberry PiをLinuxパソコンにする
Raspberry PiのOSを起動し,ブラウザからLISTENにアクセスして,音声を再生させ,シャットダウンするところまで。思ったより時間がかかりましたが,これでフェーズ1(開封確認から音出しまで)が無事終わりました。7. ケースへ仮組みする ケースにRaspberry Piを入れます。 この段階では、できれば「完全固定」より「確認しながら仮組み」がよいです。 1. スペーサーを取り付ける 2. Raspberry Piを置く 3. ネジ位置が合うか確認 4. ファンやヒートシンクの位置を確認 5. ケーブル類が挿せるか確認 6. microSDカードが抜き差しできるか確認 ファンのケーブルがある場合は、説明書通りに接続します。 向きを間違えないように、ここは慎重に。 8. 周辺機器を接続する 電源はまだ挿しません。 まず周辺機器を先につなぎます。 1. 小型液晶モニタとRaspberry PiをHDMIケーブルで接続 2. USBキーボードを接続 3. USBマウスを接続 4. USBスピーカーを接続 Raspberry Pi 5側はmicroHDMI、モニタ側がminiHDMIなら、今回買ったmicroHDMI-miniHDMIケーブルで合っています。 9. 最後に電源を挿す ここで初めてUSB-C電源を挿します。 電源を挿す ↓ Raspberry Piが起動する ↓ 画面にロゴや起動画面が出る ↓ しばらく待つ Raspberry Piは、電源を挿すと起動します。 途中で焦って抜かないでください。初回起動は少し時間がかかることがあります。 10. 初回起動を確認する 画面が出たら、次を確認します。 デスクトップ画面が表示される マウスが動く キーボード入力できる 時刻が日本時間になっている Wi-Fiがつながっている Wi-Fiがつながっていなければ、画面右上のネットワークアイコンから接続します。 Raspberry Pi OSでは、近くのWi-Fiネットワークを表示して接続できます。 11. ブラウザでWeb確認 Chromiumブラウザを開いて、Webページが見られるか確認します。 まずは軽いページでよいです。 Raspberry Pi公式サイト LISTEN YouTube ここでインターネット接続が確認できます。 12. 音が出るか確認 次にUSBスピーカーを確認します。 1. USBスピーカーを接続 2. 画面右上の音量アイコンを確認 3. 出力先がUSBスピーカーになっているか見る 4. YouTubeなどを再生 5. 音が出るか確認 音が出ない場合は、まず音量アイコンから出力先を確認します。 HDMI側に音が出ている場合があります。 13. シャットダウンの練習 フェーズ1でかなり大事なのが、正しい終了方法です。 Raspberry Piは動作中にいきなり電源を抜かない方がよいです。 microSDカードのデータが壊れる可能性があります。 終了するときは、画面のメニューから、 Shutdown を選びます。 画面が消えて、アクセスランプの点滅が落ち着いてから電源を抜きます。 14. 2回目の起動確認 一度シャットダウンしたら、もう一度電源を入れてみます。 電源を挿す ↓ 起動する ↓ Wi-Fiにつながる ↓ ブラウザが開く ↓ 音が出る ここまで再現できれば、かなり安心です。 15. Macから接続する準備 ここまで成功したら、次に遠隔操作の準備です。 まず、Raspberry Piのホスト名を確認します。 Imagerで xxxxxxxx にしていれば、Macのターミナルから次のように試せます。 ssh ユーザー名@xxxxxxxx.local 初回は確認メッセージが出るので、yes と入力します。 その後、パスワードを入力します。 入れたら成功です。 MacからRaspberry Piに入れる これは後のフェーズでかなり便利です。 16. Raspberry Pi Connectはその次でよい Raspberry Pi Connectを使うと、ブラウザからPiの画面やシェルにアクセスできます。公式ドキュメントでは、Connectアイコンから有効化し、Raspberry Pi IDで紐づける手順が説明されています。 ただ、フェーズ1では急がなくてよいです。 順番としては、 まず画面ありで成功 次にSSH その後にRaspberry Pi Connect で十分です。 フェーズ1の完了条件 次が全部できたら、フェーズ1は完了です。 □ Raspberry Pi 5が起動した □ Wi-Fiにつながった □ ブラウザでWebページを開けた □ USBスピーカーから音が出た □ 正しくシャットダウンできた □ 2回目も問題なく起動できた □ 可能ならMacからSSH接続できた この最後のSSHは「できれば」で大丈夫です。 本来のフェーズ1完了条件は、まず「Linuxパソコンとして普通に使える」ことです。 最初の日は、欲張らずに 「起動・Wi-Fi・音出し」 までで十分です。 そこまで行けば、TanaRadio Piはもう「部品の山」ではなく、ちゃんと動き始めた道具になります。#声日記 #TanaRadioPi LISTENで開く
-
352
359 日記 | OSを書き込む
Raspberry Pi OSをmicroSDカードに書き込みました。2. Raspberry Pi本体の確認 Raspberry Pi 5本体を取り出して、ざっと見ます。 見るところはここです。 基板が割れていないか USB端子が曲がっていないか microHDMI端子が曲がっていないか USB-C電源端子が曲がっていないか GPIOピンが曲がっていないか microSDカードスロットが破損していないか GPIOピンは、将来スイッチやダイヤルをつなぐ大事な部分です。 ピンが少し曲がっている程度なら直せる場合もありますが、最初に見ておくと安心です。 3. ケース説明書を読む 次に、メタルケースの説明書を軽く読みます。 特に確認するのは次です。 Raspberry Pi 5の向き スペーサーの位置 ファンの向き ヒートシンクの貼り付け位置 GPIOにアクセスできるか ケースを閉じる前にmicroSDを挿す必要があるか ここで大事なのは、最初から完全にケースへ組み込まなくてもよいということです。 不安なら、最初の起動確認だけは裸のPi、またはケースを完全に閉じない状態でやっても構いません。 ただし、金属片やネジが基板に触れないようにしてください。 4. microSDカードにRaspberry Pi OSを書き込む MacまたはPCで作業します。 Raspberry Pi公式では、Raspberry Pi Imagerを使ってOSをmicroSDカードなどの起動メディアに入れる手順が案内されています。 手順はこうです。 1. Mac/PCにRaspberry Pi Imagerをインストール 2. microSDカードをカードリーダーに挿す 3. Raspberry Pi Imagerを起動 4. デバイスで「Raspberry Pi 5」を選ぶ 5. OSで「Raspberry Pi OS 64-bit」を選ぶ 6. ストレージでmicroSDカードを選ぶ 7. 設定を編集する 8. 書き込みを実行 9. 書き込み完了後、安全に取り外す 5. Imagerの設定 書き込み前に「設定を編集」またはカスタマイズ設定を開きます。 設定しておくとよいものは次です。 ホスト名: ユーザー名:任意 パスワード:任意 Wi-Fi SSID:自宅または研究室のWi-Fi名 Wi-Fiパスワード:そのパスワード Wi-Fi国:JP ロケール:日本 キーボード:Japanese または使うキーボードに合わせる タイムゾーン:Asia/Tokyo SSH:有効にしておく 公式ドキュメントでも、Imagerのカスタマイズ設定でホスト名、ユーザーアカウント、ネットワーク接続、SSHなどを設定できると説明されています。 初回はモニタをつなぎますが、SSHを有効にしておくと後でMacから入りやすくなります。#声日記 #TanaRadioPi LISTENで開く
-
351
358 日記 | TanaRadio Pi 部品開封の儀
Raspberry Piによるインターネットラジオ受信機 (TanaRadio Pi) を作るシリーズ。今回は,購入部品の開封の儀。Raspberry Pi 5 4GBACアダプター 5.1V / 5.0Ainnomaker Raspberry Pi 5用アルミケースKIOXIA microSDカード 64GBmicroHDMI - miniHDMI ケーブルフェーズ1 手順書:開封確認から音出しまで 0. 作業前に準備するもの 購入した5点に加えて、手元のものを準備します。 購入品: 1. Raspberry Pi 5 4GB本体 2. 27W USB-C電源 3. microSDカード 64GB 4. メタルケース 5. microHDMI - miniHDMIケーブル 手元のもの: 6. microSDカードリーダー 7. 小型液晶モニタ 8. USBキーボード 9. USBマウス 10. USBスピーカー 11. MacまたはPC 12. Wi-FiのSSIDとパスワード 1. 開封確認 届いたら、まず机の上に全部並べて確認します。 ここで焦って組み立てない方がいいです。 確認するものは次の通りです。 Raspberry Pi 5本体 27W USB-C電源 microSDカード 64GB メタルケース本体 ケース付属のネジ・スペーサー・ファン・ヒートシンク microHDMI - miniHDMIケーブル 説明書 特にケース付属品は小さいので、袋をすぐ捨てないでください。 ネジが一つ行方不明になるだけで、作業が急に宝探しになります。しかも全然楽しくないタイプの宝探しです。#声日記 #TanaRadioPi LISTENで開く
-
350
357 日記 | ラジオを作りたい
インターネットラジオ受信機 (TanaRadio Pi) を作りたいと思い,部品を注文しました:・Raspberry Pi 5 4GB・ACアダブター(5.1V/5A, 27W)・microSD 64GB・ケース・microHDMI-miniHDMIケーブル#声日記 #ラジオ #RaspberryPi #TanaRadioPi LISTENで開く
-
349
356 トランシーバー声日記
マダナイというポッドキャストが始まったようです。LISTENとは相性が良さそうです。#トランシーバー声日記 #声日記 LISTENで開く
-
348
355 日記 | TanaRadioの用語集を作ってみました
TanaRadio用語集(試作版)を作ってみました。→ TanaLexicon 2026.6.7版#声日記 LISTENで開く
-
347
354 日記 | 朗読劇台本課題は結構面白い
参考:323 メモ | 朗読劇台本をAIと共に書く授業#声日記 LISTENで開く
-
346
353 ポッドキャストの書籍化その後 | テックLTポッドキャストカレンダー
現在制作中の書籍の書評です。評者はClaudeです。書評 『声で考える』著者は私立工科大学の教員で,科学史を専門とする。2023年秋,職場でも学会でもない場所で,ふと「ラジオをやりたい」と口に出した。その一言から,370回を超えるポッドキャスト配信が始まり,本書はその記録から生まれた。「声には,書かれた言葉とは異なる力がある」本書を貫く命題は,一見単純に見える。しかし著者はこれを,自己啓発書の決め台詞としてではなく,自らの体験的実証として差し出す。声で語ることは「探索」であり,書くことは「整理」である。この区別は粗っぽいようで,読み進むうちに思いのほか深い射程を持つことがわかる。本書の発見のひとつは,「語り本」と「読み本」という概念の導入である。平家物語の文献学から借用したこの区分──語られた言葉がそのまま文字になったものと,読むために書かれた言葉──を,著者はポッドキャストとテキストの関係に重ねる。声日記の文字起こしを読むとき,その人の声が脳内で自動再生される。しかし同じ人の学術論文を読んでもそうはならない。この素朴な観察が,「整った文章は個性を消す」という論点へと転がっていく。第1部でこの問いを立てた著者は,第2部の教育論へと向かう。その転換を橋渡しするのが,第3章の末尾に置かれた一節だ。授業への手応えのなさ,学生の主体性のなさ,30年分のモヤモヤを語り続けた末に著者が気づいたのは,個人的な不満の背後にある,より根の深い問題だった。「今日の学校教育が長年にわたって軽視してきたものの正体」——それは,声が担っている逡巡,身体的なリズム,整理される前の曖昧さ,そのすべてである。頭だけで考え,頭だけで答える人間を育てることに,学校教育は最適化されてきた。この見立てによって,著者個人のモヤモヤと制度批判が,一本の論理で結ばれる。第2部の教育論は,その軸の上に乗っている。学級制が子どもと教員の双方を無力化するという教育学者・柳治男の分析,不登校を「自由学習」として積極的に評価する視座,「答えようとするな,むしろ問え」という孫泰蔵の言葉の再解釈。そして「AIが書いたような感想文を学生が書き,クラスメートがいいねをつける」という授業の現場の描写。12年間の学校教育に適応することで,人はある意味でAIのように振る舞うことを学んでしまう——この一点において,第1部の「声の固有の力」と第2部の教育批判は,たしかにつながっている。第3部では,AIとの協働体験が報告される。バイブコーディングの挫折と再挑戦,一ヶ月でウェブアプリを完成させた驚き,「AIとの対話を読み返すとき,AIの声は聞こえないが,自分が投げかけた問いの声は聞こえる」という気づき。ここは本書でもっとも読み物として勢いがある箇所で,著者の知的体力を感じる。本書には,きわめて変わった出版構造がある。この読み本の背後に,370回の語り本──すべてLISTEN上で公開されているポッドキャスト配信──が実際に存在している。読者はこの本を読みながら,著者の肉声に戻ることができる。書くことと語ることの「補完関係」を,著者は主張するだけでなく,本の構造そのものに埋め込んだ。この仕掛けの誠実さを,評者は買う。声は消える。しかし,考えた声の痕跡は残る。著者はその痕跡を,読み本という形式に定着させることで,語り本には回収できない何かを書いた。その「何か」の正体を確かめるために,本書を読み,そしてLISTENに向かってほしい。 参考:345 日記 | ポッドキャストの書籍化 #テックLTポッドキャストカレンダー LISTENで開く
-
345
352 odajinさんのライブ配信に参加しました | トランシーバー声日記
#トランシーバー声日記 LISTENで開く
-
344
351 できた!から違和感へ,そして… | テックLTポッドキャストカレンダー
第5回テックLTポッドキャストカレンダー5月2日担当。参考:出口康夫「AI時代に人間は不要か?」(国立情報学研究所シンポジウム, 2026.4.24)関連:241 エラーを肯定す | NOT (TanaRadio, 2025.2.12)#テックLTポッドキャストカレンダー LISTENで開く
We're indexing this podcast's transcripts for the first time — this can take a minute or two. We'll show results as soon as they're ready.
No matches for "" in this podcast's transcripts.
No topics indexed yet for this podcast.
Loading reviews...
Loading similar podcasts...