TanaRadio
spotify apple_podcasts amazon_music
L

TanaRadio

たな 412 Episodes
たな
チャッピー
ジェームズ
サリー
ひとみ
エディ

👑 現在のトップリスナー

匿名リス
1

私立工科系大学教員(科学史)の日々の出来事や雑感。日記,メモ,ブログなど
TanaRadio Blog ( https://tanakahi.hatenablog.com/ )

389 日記 | 基板化を完了

389 日記 | 基板化を完了

Jul 21, 2026 23:44 たな

音量調節用のロータリーエンコーダーを取り付け,これで一通り部品を基板に取り付けました。ごちゃごちゃした配線はみな箱の中に納めてすっきりしました。#声日記 #TanaRadioPi

388 日記 | 箱ラジオができました

388 日記 | 箱ラジオができました

Jul 20, 2026 28:28 たな

ユニバーサル基板にLED 2個と抵抗2個をハンダ付けして,選んでいる棚とモードがLEDの点滅回数でわかるようになりました。さらに,基板を紙箱に取り付け,Raspberry Piのケースに被せて,「箱ラジオ」ができました。#声日記 #TanaRadioPi

387 日記 | 押しボタンを5つに増やす

387 日記 | 押しボタンを5つに増やす

Jul 19, 2026 29:28 たな

基板化作業の続き。前回同様,タクトスイッチ2個と抵抗2個をハンダ付けして,棚選びとモード選びができるようにしました。残る部品はLED 2個とロータリーエンコーダーです(録音では,ロータリーエンコーダーだけのように言っていますが,間違いです)。#声日記 #TanaRadioPi

386 日記 | 押しボタンを3つに増やす

386 日記 | 押しボタンを3つに増やす

Jul 19, 2026 31:01 たな

基板化作業の続き。タクトスイッチ2個と抵抗2個をハンダ付けして,プレイリスト上のエピソードを進めたり戻したりできるようにしました。#声日記 #TanaRadioPi

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

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

Jul 19, 2026 05:00 たな

第6回 テックLTポッドキャストカレンダー参加エピソードです(7月19日分)。約1ヶ月前の6月13日に始めたポッドキャスト受信機「TanaRadio Pi」の製作を通して発見した2つのことについて語っています。*TanaRadio Piの製作日記をTanaRadioで配信しています。→ #TanaRadioPi現在のTanaRadio Pi (愛称: カゴラジオ)参考:301 AIによる堅実な開発の20日間 | テックLTポッドキャストカレンダー#テックLTポッドキャストカレンダー

384 日記 | 基板へのハンダ付けとスイッチ動作確認

384 日記 | 基板へのハンダ付けとスイッチ動作確認

Jul 16, 2026 14:33 たな

基板への部品ハンダ付けの続き。今日はタクトスイッチ1つと抵抗1つを取り付け,スイッチの動作確認をしました。#声日記 #TanaRadioPi

383 日記 | 基板に部品をハンダ付け

383 日記 | 基板に部品をハンダ付け

Jul 15, 2026 32:43 たな

基板に部品をハンダ付けする作業を始めました。まずは,ピンヘッダーとグラウンド線です。#声日記 #TanaRadioPi

382 日記 | 基板に部品を配置

382 日記 | 基板に部品を配置

Jul 14, 2026 03:00 たな

昨日作った実体配線図通りには基板に置けなかったので,一部配置を変えました。#声日記 #TanaRadioPi

381 日記 | 回路図を書いてみました

381 日記 | 回路図を書いてみました

Jul 13, 2026 18:21 たな

2016年7月13日月曜日の声日記。TanaRadio Piの回路図(正確には実体配線図)など。カゴラジオ#声日記 #TanaRadioPi

380 日記 | 電源オンで自動起動

380 日記 | 電源オンで自動起動

Jul 12, 2026 28:25 たな

電源オンで音が出るようにしました。見かけはともかく,機能的にはほぼ期待通りのモノができました(フェーズ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 tana@raspberrypi.local 入れたら、状態確認はこれです。 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

379 日記 | Raspberry Piをケースに入れる

379 日記 | Raspberry Piをケースに入れる

Jul 10, 2026 33:47 たな

フェーズ5Jに行こうとしましたが,その前にRaspberry Piをケースに入れるところまでで終わってしまいました。*なぜかノイズキャンセリングが効いているようで,とても聴きにくいです。#声日記 #TanaRadioPi

378 日記 | ロータリーエンコーダーで音量調節

378 日記 | ロータリーエンコーダーで音量調節

Jul 9, 2026 31:39 たな

まず,ロータリーエンコーダーを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

377 日記 | 再生モードボタンをつける

377 日記 | 再生モードボタンをつける

Jul 8, 2026 17:40 たな

再生モード切り替えのためのボタンと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

376 日記 | 棚選択ボタンとLEDランプを追加

376 日記 | 棚選択ボタンとLEDランプを追加

Jul 6, 2026 26:21 たな

ボタンで棚を選択できるようにしました。選んでいる棚番号は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

375 日記 | 棚を選択してプレイリストを作成

375 日記 | 棚を選択してプレイリストを作成

Jul 5, 2026 29:54 たな

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

374 日記 | 物理ボタンでプレイリスト再生を制御

374 日記 | 物理ボタンでプレイリスト再生を制御

Jul 4, 2026 25:57 たな

物理ボタンでプレイリスト再生の制御ができるようになりました(フェーズ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

373 講義 | 「ほどよい」技術

373 講義 | 「ほどよい」技術

Jul 3, 2026 19:02 たな

この音源は、最近収録した今学期の「科学と技術の社会史」の講義(ビデオ教材)の最後の部分です。現在製作中のTanaRadio Piの話も出てきます。#講義 #TanaRadioPi

372 日記 | 複数RSSで過去3日分を古い順に再生

372 日記 | 複数RSSで過去3日分を古い順に再生

Jul 2, 2026 12:13 たな

複数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

371 日記 | 過去3日分を古い順に再生

371 日記 | 過去3日分を古い順に再生

Jul 1, 2026 13:10 たな

あるポッドキャストの過去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

370 日記 | RSSからプレイリストを作る

370 日記 | RSSからプレイリストを作る

Jun 30, 2026 19:53 たな

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

【ショート】STEAM NEWSまとめ

【ショート】STEAM NEWSまとめ

配信テスト中です. https://listen.style/p/steamnews?461WWtmZ

将棋指せるかな?

将棋指せるかな?

将棋指せるかな?は、将棋がしたい!という有名ポッドキャストを配信されているだまさんのお嬢様が5歳になったら対決させていただくのを目標に、おばちゃんポッドキャスターのMoが将棋を学んでいく様子をお伝えする番組です。木曜日が好きなので、毎週木曜日に配信(予定笑)

LISTEN_staff

LISTEN_staff

テスト用アカウントです。 ポッドキャストの書き起こしサービス「LISTEN」はこちら https://listen.style/p/staff_test?uT1WThX6

TanaRadio 2

TanaRadio 2

私立工科系大学で科学史を教えている教員がお送りする有料配信チャンネル。(現在,休止中)

Kanayafrica

Kanayafrica

エジプトのこと,長崎のこと,世界遺産のことについてお話をします. 新型コロナウイルス感染症(2020年)の影響で大学の講義のオンライン化,リモート化が進んでいます.従来は講義の合間に余談を挟むことが出来たのですが,オンライン授業などではなかなか出来ないので,余談だけ切り出してポッドキャストにしました. YouTube版はこちらです👉 https://www.youtube.com/channel/UCzoDl88mrhS2Z2_8L6PfLRA 匿名質問を受け付けています.回答はツイッターで行います👉 https://marshmallow-qa.com/kanaya https://listen.style/p/kanaya?ATttQN7X

無理難題RADIO

無理難題RADIO

日々の無理難題をお話しします。 文字起こしはこちらから👉 https://listen.style/p/muri?uUNIFWL8