PythonでMP3再生(オブジェクト指向らしく?考えてみる)
考察
出発点
前回、
MP3の音源再生を「非同期」を使って実現しました。こんなコード。
import asyncio
from threading import Thread
import customtkinter as ctk
from just_playback import Playback
class App(ctk.CTk):
def __init__(self, title):
# main window
super().__init__()
self.title(title)
# widget
ctk.CTkButton(self, text="再生", command=self.on_button_click).pack(
padx=50, pady=10
)
# mainloop
self.mainloop()
def on_button_click(self):
# 非同期関数を別スレッドで実行
Thread(target=lambda: asyncio.run(self.play_sound())).start()
# 非同期で音を再生する関数
async def play_sound(self):
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self.playmp3)
def playmp3(self):
print("playmp3")
playback = Playback()
playback.load_file("test.mp3")
playback.play()
while playback.active: # 再生が終了するまで待機
pass
App("MyMP3Player")
ここまでは期待通り動いたので、これを出発点に、いろいろ素人考察をしてみたいと思います。
非同期を隠す
まず、上のコードだと、アプリのクラスAppのメソッドに
async def play_sound()という非同期メソッドが顔を出している。
非同期なので、ちょい、ちょい、と、ボタンを複数回クリックすると、それぞれが別のスレッドで走って、音が重なって再生されてしまうなんて問題があります。よろしくない。そもそも、Appクラスのメソッドとして非同期のメソッドが目につくところに出てくるのもよろしくない気がします。
別に「(customtkinterから継承している)Appクラス」自体の中に非同期処理を持ち込みたいわけではない。
Appの役割はplay とかstopとか指示を出すことだけ。
同期だの非同期だのややこしいことを工夫して「いい感じ」にこなすのはAppに使役される立場の「playerの役割を持つクラス」側でやってくれたらいいこと。非同期の処理やらなんやらは、新たに「playerクラス」を作ってそちらに隔離しちゃいましょう。
イメージとしては、AppクラスはDJご本人で、playerクラスは機材(ターンテーブル?)。
Singletonパターンは必要か?
現実世界で、DJが「音を出したい」という要求をするたびに、新しい機材を持ってきてDJに渡すようなことはしませんよね。DJには、つかうべき機材を最初に一つ渡して、最後までそれを使い倒してもらう。
プレイヤー(機材)のインスタンスが複数作られないように、先日かじったデザインパターンの一種、「Singletonパターン」なんてものを使ってみるか?と一旦は思いましたが、今の段階ではそこまでする必要もないだろう、と考えなおしました。
Singletonパターンは、「あるオブジェクトが持つ機能が必要になったときに、その都度オブジェクトのインスタンスを作成して使うという使い方」を前提にして、うっかり複数のインスタンスを作ってしまわないようにするテクニック。
だけれど、MP3プレイヤーアプリの場合は「プレイヤー」の機能は必須だから必ず一つは作る。その一方で再生の都度作る必要はない。プレイヤーを最初に一つ作成し、持っておくことは、コスト増にはならない。
Appのinitのときにplayerインスタンスを一つ作って、最後までずっと保持し続ければいい。Appの初期化メソッド(init)以外で、新規にplayerを作成すると言うようなことさえしなければ、わざわざSingletonパターンで書く必要はないよね?
・・自分への「やらない言い訳」ができました(笑)
Player抽象クラス
playerのクラスを書いてみます。
just_playbackのPlaybackクラスを「継承」することも当初考えましたが、やめました。先々、いろいろなモジュールを差し替えて使うことが考えられますから、just_playbackに過度に依存するのはよくない。
Pythonでの抽象クラスの書き方を探していたら
@TrashBoxx様のこちらの記事と
@Jazuma様のこちらの記事を見つけました。
これらの記事で抽象クラスの書き方を把握。
まず、抽象クラスCustomPlayerを定義。そのうちplayer機能が備えておくべきメソッドと言うのを増やすかもしれないけど、とりあえず「play」メソッドは無いと話にならないのでこれだけ置いて始めてみます。
さらに、それを継承して、just_playback を組み込んだ、MyPlayerクラスを作成。見よう見まねで書いてみます。
import asyncio
from abc import ABC, abstractmethod
from threading import Thread
from just_playback import Playback
class CustomPlayer(ABC):
@abstractmethod
def play(self):
pass
class MyPlayer(CustomPlayer):
def __init__(self):
super().__init__()
self.mp3file = None
self.playback = Playback()
def set_mp3file(self, filename):
self.mp3file = filename
self.playback.load_file(filename)
# 同期の書き方でplayの処理を書く
def playsync(self):
if self.mp3file is not None:
self.playback.play()
while self.playback.active:
pass
# 非同期の書き方でrun_in_executorでラップ
async def playasync(self):
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self.playsync)
# 抽象メソッドを実装
def play(self):
# 非同期関数を別スレッドで実行
if not self.playback.active:
Thread(target=lambda: asyncio.run(self.playasync())).start()
こんな感じですかね?これを player_lib.py というライブラリファイルに保存します。
アプリにする
では、そのライブラリを呼び出して使う形でアプリを書こう。
import customtkinter as ctk
from player_lib import MyPlayer
class App(ctk.CTk):
def __init__(self, title):
# main window
super().__init__()
self.title(title)
# player
self.player = MyPlayer()
self.player.set_mp3file("test.mp3")
# widget
ctk.CTkButton(self, text="再生", command=self.player.play).pack(
padx=50, pady=10
)
# mainloop
self.mainloop()
App("MyMP3Player")
われながらめずらしいことに「一発動作」してくれました。
・「再生」をクリックして、音楽再生が始まる。
・再生中もGUIは固まらない。
・再生中に「再生」をクリックしても重複再生は生じない。(これ大事)
・再生終了後に「再生」をクリックすれば、あらためて音楽再生が始まる。
当初思い描いていた理想の動作になっています。
ただ、音楽再生中に右上の×でウィンドウを閉じると・・ウィンドウは閉じたままで音楽の再生が最後まで続きます。これは強制的に再生を終了するなり、考えないといけないですね。
改善
Copilot先生に聞きます。
いいですね。まあ、いちいち「終了しますか?」とか聞かないで音楽の再生を止めて、閉じてしまえば良いと思います。
player_lib.py のMyPlayerクラスには次のように、stopメソッドを追加しました。
import asyncio
from abc import ABC, abstractmethod
from threading import Thread
from just_playback import Playback
class CustomPlayer(ABC):
@abstractmethod
def play(self):
pass
class MyPlayer(CustomPlayer):
def __init__(self):
super().__init__()
self.mp3file = None
self.playback = Playback()
def set_mp3file(self, filename):
self.mp3file = filename
self.playback.load_file(filename)
# 同期の書き方でplayの処理を書く
def playsync(self):
if self.mp3file is not None:
self.playback.play()
while self.playback.active:
pass
# 非同期の書き方でrun_in_executorでラップ
async def playasync(self):
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self.playsync)
# 抽象メソッドを実装
def play(self):
# 非同期関数を別スレッドで実行
if not self.playback.active:
Thread(target=lambda: asyncio.run(self.playasync())).start()
# stop機能を追加
def stop(self):
self.playback.stop()
ライブラリを呼び出すアプリ側は次のようにon_closingメソッドを追加し、mainloopの前に、self.protocol("WM_DELETE_WINDOW", self.on_closing) を書き足しました。
import customtkinter as ctk
from player_lib import MyPlayer
class App(ctk.CTk):
def __init__(self, title):
# main window
super().__init__()
self.title(title)
# player
self.player = MyPlayer()
self.player.set_mp3file("test.mp3")
# widget
ctk.CTkButton(self, text="再生", command=self.player.play).pack(
padx=50, pady=10
)
# mainloop
self.protocol("WM_DELETE_WINDOW", self.on_closing)
self.mainloop()
def on_closing(self):
# 閉じる前にやること
self.player.stop()
# 閉じる
self.destroy()
App("MyMP3Player")
以上で、ウインドウを閉じた時の動作も含めて、理想的なものになりました。
今回はすごく達成感があります♡