冥鳴ひまりのMP3プレイヤーを作った

この間作ったラジオ風のMP3プレイヤーを進化させました。
https://qiita.com/butaushi/items/fe8817107601f405f730

tkinterも少しずつわかってきたので背景に画像を設定。
せっかくなので、冥鳴ひまりの立ち絵をお借りして。
なんとか動きをつけたいなぁ、と悩んだ結果、ハートをピコピコさせるだけにしました。
ちなみに、ハートのリズムは、MP3の曲調(BPM)に合わせて変わります。
Pythonでlibrosaというライブラリでできました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4042331/19e691ec-d924-48a3-8672-4fa94aec8a30.png)

ということでまた全文投下。
コアなロジックは前回からあんまり変わっていません。
(フェードアウトのタイミングだけ微調整しました）

他にも、以下機能追加。
・プレイリスト機能追加
・ひまりがたまにしゃべる

お借りした素材はこちらです。
・MP3プレイヤーのフレーム
【1280×720☆背景素材】冥鳴ひまりフレーム③
https://commons.nicovideo.jp/works/nc364749

・立ち絵素材
冥鳴ひまり立ち絵素材【とらっかぁ】
https://seiga.nicovideo.jp/seiga/im10916868

```python:himariradio.py
#!/home/shu/python/radio/radio_venv/bin/python
"""
 機能：MP3をラジオっぽく再生する
	  見た目が「冥鳴ひまり」になりました
 注意：フォルダ内にあるmp3をランダムで再生
"""
import os
import glob
import threading
import subprocess
import time
import random
import atexit
import librosa		# pip install librosa

import tkinter as tk
from tkinter import filedialog
import pygame
from mutagen.mp3 import MP3

# 定数
FOLDER_PATH = "/home/shu/music/"		# 初期フォルダ
BG_COLOR = "#dfdbda"		# 背景フレームに合わせた色。擬似的に透過。
FILE_DIALOG_COLOR = "#8b008b"
FILE_DIALOG_COLOR_HOVAR = "#4b0082"


# ひまりの声 絶対パスでないとダメっぽい
JINGLE_VOICE = [ "/home/shu/python/radio/sound/voice/himari/next.wav"
				,"/home/shu/python/radio/sound/voice/himari/love.wav"
				,"/home/shu/python/radio/sound/voice/himari/pman.wav"
				,"/home/shu/python/radio/sound/voice/himari/niceday.wav"
				,"/home/shu/python/radio/sound/voice/himari/journey.wav"
				,"/home/shu/python/radio/sound/voice/himari/sky.wav"
				]

global t1
global nowplaying		# 0：再生していない、1：再生中
global playlist			# フォルダ内のMP3リスト
global listnum			# 再生中のリスト番号
global musictitle		# ファイル名（表示用）
global playtime			# 再生時間
global gtempo			# BPM

global process


"""
 main関数
"""
class HimariRadioPlayer:
	"""
	初期化
	"""
	def __init__(self, root):
		
		global t1, nowplaying, playlist, listnum, musictitle, playtime, gtempo
		
		t1 = 0
		nowplaying = 0
		playtime = 0
		listnum = 0
		musictitle = FOLDER_PATH		# 初期フォルダを表示。変なコメント出すよりも有効。
		self.img = [None, None, None]			# 画像用リスト
		self.j = 0
		gtempo = 60			# ゼロ割になるので60=1s更新で
		
		# ウィンドウ作成
		self.root = root
		self.root.title("ひまりMP3プレイヤー")
		self.root.geometry("800x450")  # ウィンドウサイズ
		
		# 画像ファイルの読み込み
		self.img_frame = tk.PhotoImage(file="/home/shu/picture/himari/himari_frame_m.png")
		self.img[0] = tk.PhotoImage(file="/home/shu/picture/himari/normal_c.png")
		self.img[1] = tk.PhotoImage(file="/home/shu/picture/himari/himari_heart.png")
		self.img[2] = tk.PhotoImage(file="/home/shu/picture/himari/himari_heart2.png")
		
		# 背景（フレーム）の配置
		self.label1 = tk.Label(root, image=self.img_frame)
		self.label1.place(x = -1, y = -1)		# ウィンドウ枠を見えなくするために-1で設定する。

		# ひまり画像配置
		self.label2 = tk.Label(root, image=self.img[self.j])
		self.label2.configure(bg = BG_COLOR, width=320, height=364)
		self.label2.place(x = 460, y = 70)

		# 再生ボタン
		self.playbutton = tk.Label(root, text = "▶️")
		self.playbutton.configure(bg = BG_COLOR, foreground = "#32cd32", font=("",20))
		self.playbutton.bind("<Button-1>", self.mouse_click)
		self.playbutton.bind("<Motion>", self.mouse_on)
		self.playbutton.bind("<Leave>", self.mouse_leave)
		self.playbutton.place(x = 731, y = 400)
		
		# 今流れているのは
		self.titleis = tk.Label(root, text = "")
		self.titleis.configure(bg = BG_COLOR, width = 25, font=("",14))
		self.titleis.place(x = 30, y = 180)
		
		# 曲名表示
		self.label = tk.Label(root, text = musictitle)
		self.label.configure(bg = BG_COLOR, width = 43, font=("",14))
		self.label.place(x = 30, y = 250)
		
		# です！
		self.desu = tk.Label(root, text = "")
		self.desu.configure(bg = BG_COLOR, width = 15, font=("",14))
		self.desu.place(x = 330, y = 320)
		
		# ファイル指定ボタン
		self.filebutton = tk.Label(root, text = "")
		self.filebutton.configure(bg = BG_COLOR, foreground = FILE_DIALOG_COLOR, font=("",16))
		self.filebutton.bind("<Button-1>", self.on_filebutton_click)
		self.filebutton.bind("<Motion>", self.mouse_on_file)
		self.filebutton.bind("<Leave>", self.mouse_leave_file)
		self.filebutton.place(x = 731, y = 365)

		# フォルダ指定ボタン
		self.stopbutton = tk.Label(root, text = "")
		self.stopbutton.configure(bg = BG_COLOR, foreground = FILE_DIALOG_COLOR, font=("",16))
		self.stopbutton.bind("<Button-1>", self.select_folder)
		self.stopbutton.bind("<Motion>", self.mouse_on_f)
		self.stopbutton.bind("<Leave>", self.mouse_leave_f)
		self.stopbutton.place(x = 730, y = 325)

		# フォルダパスからプレイリスト作成
		make_playlist( FOLDER_PATH )

		# 更新処理（ループ 1sec）
		self.update_music()
		
		# ひまり画像用ループ
		self.update_disp()


	"""
	ひまり画像用ループ
	"""
	def update_disp(self):
		
		global nowplaying, gtempo
		
		# 再生中のみ画像更新
		if nowplaying == 1:
			# ひまり画像更新
			self.j = self.j + 1
			if self.j > 2:
				self.j = 1
			self.label2.configure(image=self.img[self.j])
			self.titleis.configure(text="今流れている曲は…") 
			self.desu.configure(text="です！") 
		else:
			self.label2.configure(image=self.img[0])
			self.titleis.configure(text="")
			self.desu.configure(text="") 

		# 再度コールでループ
		# 曲のBPMに応じて更新ペースを変える
		updatetiming = 1000*60/gtempo
		# print(round(updatetiming))	# デバッグ用
		self.root.after(round(updatetiming), self.update_disp)


	"""
	ループ処理　ある意味メイン処理
	"""
	def update_music(self):
		
		global t1, nowplaying, playlist, listnum, musictitle, playtime
		
		# 指定時間経過で次の曲再生
		if (nowplaying == 1) and (time.time() - t1 > playtime):
			# 次の曲
			listnum = listnum + 1
			
			# 全曲再生したら停止
			if listnum >= len(playlist):
				nowplaying = 0
				musictitle = "全部流したよ"
				self.playbutton.configure(text = "▶️")
			else:
				# ひまりの一言
				thread_v = threading.Thread(target=playjingle).start()
								
				# 次の音楽作成
				musicpath = make_music()
				
				# 再生後もGUIは操作したいのでスレッドを分ける
				thread = threading.Thread(target=playmusic,args=(musicpath, playtime)).start()
				
				# 現在時刻取得
				t1 = time.time()

		# タイトルラベル更新
		self.label.config(text = musictitle)
		
		# 1秒後に再度コール
		self.root.after(1000, self.update_music)

	"""
	 機能：ファイル選択ボタン
	"""
	# マウスオーバー
	def mouse_on_file(self, e):
		self.filebutton.configure(foreground = FILE_DIALOG_COLOR_HOVAR)

	# マウスリリース
	def mouse_leave_file(self, e):
		self.filebutton.configure(foreground = FILE_DIALOG_COLOR)

	"""
	 機能：ファイル(プレイリスト)選択ダイアログ
	"""
	def on_filebutton_click(self, e):
		
		global playlist, musictitle
		
		# ファイル拡張子はtxt限定
		# フォルダダイアログ表示時の初期フォルダは、ユーザのホームフォルダ
		file_name = tk.filedialog.askopenfilename(filetypes=[("Play List", ".txt")], initialdir="~/")
		# ファイルが選択されたらプレイリスト更新
		# 一旦正しく書かれている前提
		if file_name:
			with open(file_name, "r", encoding="utf-8") as f:
				playlist = [line.strip() for line in f.readlines()]  # 改行を削除
				musictitle = file_name


	"""
	 機能：フォルダ選択ボタン
	"""
	# マウスオーバー
	def mouse_on_f(self, e):
		self.stopbutton.configure(foreground = FILE_DIALOG_COLOR_HOVAR)

	# マウスリリース
	def mouse_leave_f(self, e):
		self.stopbutton.configure(foreground = FILE_DIALOG_COLOR)


	"""
	 機能：フォルダ選択
	"""
	def select_folder(self, e):
		
		global musictitle
		
		# 停止中のみ変更可
		if nowplaying == 0:
			# ダイアログ表示
			folder_path = filedialog.askdirectory(initialdir="~/")
			
			if folder_path:
				print("選択されたフォルダ:", folder_path)
				musictitle = folder_path
				make_playlist(folder_path)


	"""
	 ボタン表示処理
	"""
	# マウスオーバー
	def mouse_on(self, e):
		self.playbutton.configure(foreground = "#808000")

	# マウスリリース
	def mouse_leave(self, e):
		if nowplaying == 0:
			self.playbutton.configure(foreground = "#32cd32")
		else:
			self.playbutton.configure(foreground = "#ff4500")

	# クリック
	def mouse_click(self, e):
		
		global musictitle
		
		if nowplaying == 0:
			if len(playlist) > 0:
				# 再生していないなら流す。ボタンもストップに変える
				self.playbutton.configure(text = "⏸️")
				on_button_click(e)
			else:
				musictitle = "再生できるMP3ファイルがありません"
		else:
			# 再生中なら止める。ボタンも再生に変える。
			self.playbutton.configure(text = "▶️")
			on_button2_click(e)



"""
 機能：ひまりの一言再生
"""
def playjingle():
	
	# 発言タイミング調整
	time.sleep(2.5)

	# 毎回言わなくてもいいとも思うので3回に1回くらい
	# 曲の切り替わりに一言
	if random.randint(0, 2) > 1:
		process_v = subprocess.Popen(["paplay", random.choice(JINGLE_VOICE)])


"""
 機能：プレイリスト作成
 備考：フォルダ内のMP3ファイルを検索してリスト化
"""
def make_playlist(folderpath):
	
	global playlist

	# フォルダ内を再帰検索してmp3のリストを作る
	searchpath = folderpath + "/**/*.mp3"
	playlist = glob.glob(searchpath, recursive=True)
	
	# 取得したMP3一覧をテキストファイルに書き込み
	with open("playlist.txt", "w", encoding="utf-8") as f:
		f.writelines(line + "\n" for line in playlist)

	# 一応作っておく
	with open("playlist.txt", "r", encoding="utf-8") as f:
		playlist = [line.strip() for line in f.readlines()]  # 改行を削除


"""
 機能：mp3再生
 備考：プロセスをわけているのは、開発当初、subprocess.runだった名残
"""
def playmusic(filepath, playtime):
	
	global process
	
	process = subprocess.Popen([
		"/home/shu/python/radio/radio_venv/bin/python",
		"/home/shu/python/radio/src/mcore.py",
		filepath,
		str(playtime)
	])



"""
 機能：再生する音楽情報の生成
 引数：なし
 戻り値：mp3ファイルパス
 備考：結局グローバル変数使いまくっているので直したい
"""
def make_music():

	global playlist, listnum, musictitle, playtime, gtempo

	# リスト番号からファイルパス取得
	musicpath = playlist[listnum]
	print(musicpath)
	
	# ファイルパス → ファイル名取得（表示用）
	musictitle, ext = os.path.splitext(os.path.basename(musicpath))
	
	# 曲の長さを取得して、2分〜曲の長さ - 10秒で再生時間確定
	# 10秒はフェードアウトを考慮(適当)
	audio = MP3(musicpath)
	playtime = random.randint(120, (int(audio.info.length) - 10))
	
	# MP3の解析
	y, sr = librosa.load(musicpath)

	# テンポ（BPM）を取得
	tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
	gtempo = tempo[0]

	print(f'Tempo: {tempo[0]} BPM')
	
	return musicpath


"""
 機能：再生ボタンクリックイベント
"""
def on_button_click(e):

	global t1, nowplaying, playtime, listnum, musictitle

	# リストをシャッフルすることでランダム再生
	random.shuffle(playlist)
	
	# リストの1曲目から
	listnum = 0
	
	# 1曲目再生するときに時間かかるのでしゃべってもらう
	# subprocess.run(["paplay", "/home/shu/python/nowboot.wav"])

	# 音楽情報取得
	musicpath = make_music()
	
	# 再生後もGUIは操作したいのでスレッドを分ける
	thread = threading.Thread(target=playmusic,args=(musicpath, playtime)).start()
	
	# 再生中に設定
	nowplaying = 1
	
	# 現在時刻取得
	t1 = time.time()


"""
 機能：停止ボタンクリックイベント
"""
def on_button2_click(e):
	
	kill_music()


"""
 機能：停止処理
"""
def kill_music():
	
	global nowplaying, musictitle
	
	# 停止に設定
	nowplaying = 0
	musictitle = "何も流してないよ"
	process.kill()
	process.wait()  # ちゃんと終わるまで待つ


"""
 終了処理
"""
# プログラム終了時の処理
atexit.register(kill_music)


"""
 main関数呼び出し
"""
if __name__ == "__main__":
	root = tk.Tk()
	app = HimariRadioPlayer(root)
	root.mainloop()

```