音源再生ライブラリ書き換え(4)
(※本内容は、python初心者による試行錯誤の過程の記録です。整理された内容ではありません。)
プログラムの勉強は習慣づけて毎日続けないとだめですね。ちょっと「別ネタ」でお茶を濁したつもりが、前回の
から、あっという間に一週間過ぎてしまいました。おそろしい。
今回はリハビリを兼ねて。
書き換えの続き
勘違い
自分が勘違いしていたことがあります。
import sounddevice as sd
とやっているsounddeviceは「クラス」だと思い込んでいたのです。前回、ソースコードを見てみたら、
(もちろんコードの中には>class OutputStream(RawOutputStream)のようなクラスも使われていますが)
sounddeviceそれ自体はクラスではなかった。
そういえば使うとき、別にイニシャライズしていなかったのだから疑問に思うべきでした。
今回の出発点
さて、書き換え作業継続します。前回何通りか書いたパターンのうち、
「GUIあり、(1)sounddeviceのplay、wait利用」のやり方を出発点にして膨らませて行こうと思います。
(出発点)
import time
from threading import Thread
import customtkinter as ctk
import numpy as np
import sounddevice as sd
from pydub import AudioSegment
class MyPlayer:
# イニシャライズ
def __init__(self):
super().__init__()
self.filename = None
self.audio = None
self.audio_data = None # オーディオデータ
self.sr = None # サンプルレート
self.is_playing = False
# ファイル読み込み
def set_mp3file(self, filename):
self.filename = filename
self.audio = AudioSegment.from_mp3(filename)
self.audio_data = np.array(self.audio.get_array_of_samples())
if self.audio.channels == 2:
self.audio_data = self.audio_data.reshape((-1, 2))
self.sr = self.audio.frame_rate
def play_audio(self):
if self.filename is not None:
if not self.is_playing:
start_time = time.time()
self.is_playing = True
# 音声の再生
sd.play(self.audio_data, samplerate=self.sr)
sd.wait()
self.is_playing = False
end_time = time.time()
print(f"{end_time - start_time:.3f}秒")
def play(self):
if not self.is_playing:
Thread(target=self.play_audio).start()
def stop(self):
if self.is_playing:
sd.stop()
if __name__ == "__main__":
class App(ctk.CTk):
def __init__(self, title):
super().__init__()
self.title(title)
self.player = MyPlayer()
self.player.set_mp3file("test2.mp3")
# widget
ctk.CTkButton(self, text="再生", command=self.player.play).pack(
padx=100, pady=10
)
ctk.CTkButton(self, text="停止", command=self.player.stop).pack(
padx=100, pady=10
)
ctk.CTkButton(
self, text="テスト1", command=self.testmethod1
).pack(padx=100, pady=10)
ctk.CTkButton(
self, text="テスト2", command=self.testmethod2
).pack(padx=100, pady=10)
# mainloop
self.protocol("WM_DELETE_WINDOW", self.on_closing)
self.mainloop()
def testmethod1(self):
print("テスト1")
def testmethod2(self):
print("テスト2")
def on_closing(self):
# 閉じる前にやること
self.player.stop()
# 閉じる
self.destroy()
App("MyMP3Player241103")
ライブラリとしても、そのままテストコードとしても実行できるように
>if __name__ == "__main__":
をつけています。(何か試すときはtestmethodのところに書いてみる)
・再生、停止の動作、
・再生中もGUIが固まらないこと、
・再生中、直接ウィンドウを閉じて終了、の操作正常を確認。
◎実行時間113.030秒(音源ファイルの長さは112.9秒)。
・・・を確認。ここまでは期待通り
足りないメソッド
以前、「just_playback」パッケージを使ったプレイヤークラスで実装していたのは、他に
@property
def paused(self):
return self.playback.paused
@paused.setter
def paused(self, flag):
if flag is True:
self.playback.pause()
else:
self.playback.resume()
@property
def pos(self):
return self.playback.curr_pos
@pos.setter
def pos(self, seekpos):
self.playback.seek(seekpos)
@property
def duration(self):
return self.playback.duration
@property
def volume(self):
return self.playback.volume
@volume.setter
def volume(self, vol):
self.playback.set_volume(vol)
です。できるだけ、これと同じように使えるように実装を試みます。
https://github.com/jiaaro/pydub/blob/master/API.markdown#audiosegment
こちらにpydubのAudioSegmentの使用例がいろいろ出ています。すごく参考になります。
duration
duration の取得方法もわかりました。pydubにこんなサンプルコードが出ています。
from pydub import AudioSegment
sound = AudioSegment.from_file("sound1.wav")
assert sound.duration_seconds == (len(sound) / 1000.0)
AudioSegmentのオブジェクトを引数にしてlenをとれば、ミリ秒単位でのdurationが取得できるということね。
MyPlayerクラスで音源ファイル読み込み後は、self.audioにAudioSegmentが入っているわけですから、これのlenを取って1000で割れば秒単位になる。class MyPlayer に次のプロパティを追加
@property
def duration(self):
return len(self.audio)/1000
・・・おっと違う違う、寝ぼけていました。duration_secondsというプロパティを読み出せばそもそも解決という話。
@property
def duration(self):
return self.audio.duration_seconds
そして、class Appに
def testmethod1(self):
print(self.player.duration)
を追加。実行テスト。テスト1クリックで(テストデータ「残業戦士」
の場合)1分52秒のデータですが、112.87510204081633と表示されました。よしよし。ここまではいい。
pos
再生中に「現在位置」を取得できるか?という問題。再生機能はpydubではなく、あくまでもsounddevice側の問題。sounddeviceのサンプルを見ていると
簡単な実装例としては
This example could simply be implemented like this::
import sounddevice as sd
import soundfile as sf
data, fs = sf.read('my-file.wav')
sd.play(data, fs)
sd.wait()
... but in this example we show a more low-level implementation
using a callback stream.
と、sd.play, sd.waitを使うが、ここではlow-levelの実装を・・なんて書いてある。そのlow-levelの実装内容というのが、前回自分が
としていた、まさにそのやめておいたOutputStreamをいじる内容のようなので、さて、この沼に足を突っ込むか否か・・悩ましいところです。まあ、一通り見てみますか。
https://github.com/spatialaudio/python-sounddevice/blob/0.5.1/examples/play_file.py
#!/usr/bin/env python3
"""Load an audio file into memory and play its contents.
NumPy and the soundfile module (https://python-soundfile.readthedocs.io/)
must be installed for this to work.
This example program loads the whole file into memory before starting
playback.
To play very long files, you should use play_long_file.py instead.
This example could simply be implemented like this::
import sounddevice as sd
import soundfile as sf
data, fs = sf.read('my-file.wav')
sd.play(data, fs)
sd.wait()
... but in this example we show a more low-level implementation
using a callback stream.
"""
import argparse
import threading
import sounddevice as sd
import soundfile as sf
def int_or_str(text):
"""Helper function for argument parsing."""
try:
return int(text)
except ValueError:
return text
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument(
'-l', '--list-devices', action='store_true',
help='show list of audio devices and exit')
args, remaining = parser.parse_known_args()
if args.list_devices:
print(sd.query_devices())
parser.exit(0)
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
parents=[parser])
parser.add_argument(
'filename', metavar='FILENAME',
help='audio file to be played back')
parser.add_argument(
'-d', '--device', type=int_or_str,
help='output device (numeric ID or substring)')
args = parser.parse_args(remaining)
event = threading.Event()
try:
data, fs = sf.read(args.filename, always_2d=True)
current_frame = 0
def callback(outdata, frames, time, status):
global current_frame
if status:
print(status)
chunksize = min(len(data) - current_frame, frames)
outdata[:chunksize] = data[current_frame:current_frame + chunksize]
if chunksize < frames:
outdata[chunksize:] = 0
raise sd.CallbackStop()
current_frame += chunksize
stream = sd.OutputStream(
samplerate=fs, device=args.device, channels=data.shape[1],
callback=callback, finished_callback=event.set)
with stream:
event.wait() # Wait until playback is finished
except KeyboardInterrupt:
parser.exit('\nInterrupted by user')
except Exception as e:
parser.exit(type(e).__name__ + ': ' + str(e))
うーん、コールバックというのを勉強しないと私には読み解けそうもありません。
邪道実装(?)
本来はちゃんと、ストリームとかコールバックとか理解してから、現在位置を求めるやりかたを実装すべきなのでしょう。
けど、ちょっと逃げて、再生時間をtime関数で測ってposにしてしまうやり方を試してみます。
play実行するときにstarttime記録して、pos呼ばれた際のtimeとの差分を取ればposを返せるよね?
MyPlayerクラスにself.playstarttimeという変数を用意して
def play(self):
if not self.is_playing:
self.playstarttime=time.time()
Thread(target=self.play_audio).start()
def stop(self):
if self.is_playing:
sd.stop()
self.playstarttime=None
再生中にposプロパティが読みだされたら、開始からの秒数を出す。
@property
def pos(self):
if self.is_playing:
return time.time() - self.playstarttime
else:
return None
試しにこれで。
def testmethod2(self):
print(self.player.pos)
testmethodのところにこれを書いておいて、再生中にポチポチ、ボタンクリックしてみる。
2.8174376487731934
4.522406101226807
4.59666895866394
7.312045097351074
9.217336177825928
11.376790285110474
うん、取得できているっぽい。なお、再生が終わってからクリックするとNoneと表示されます。
ただ、これだけでは、再生中に一時停止入れるとき困りますね。また考えてみます。