プログラミング習得への道のり #9 アプリの画面をつくる② ~PCキーボードで演奏したい~

プログラミング完全初心者の私が、プログラミングを習得するまでの全過程(主に悩み…)を記事にしていきます。

プログラミング学習にあたり、以下の順で進めています。

①アウトプットを決める、②基礎を学習する、③アウトプットを作り始める

アウトプットは PCキーボードで演奏できるアプリケーション に決めました。

前々回から、ステップ1. キーボード入力で音を鳴らすのうち、”アプリケーション画面(GUI部分)をつくりはじめる“にチャレンジしています。

はじめにpygameというライブラリを用いところ、画面がないためキーを押した瞬間にグラフは出現せず、リアルタイム性がないという課題がでました。そこで前回、Pythonで画面(GUI)をつくるためにTkinterというライブラリの基礎構造をびました。本記事では、前回の応用として “TKinterを用いてキープレスに応じて波形が変化する画面(GUI部分)をつくる” にチャレンジしました。

PCキーボード演奏アプリの作成ステップ

  1. キーボード入力で音を鳴らす
    ☑1−1.1つの音を鳴らす
    ☑1-2.音階を鳴らす
    ☑1-3.音の長さを自由に変える
    1-4.画面をつくる
    1-5.メトロノーム機能をつくる
  2. 音を制御する
    2-1.波形を変える
    2-2.フィルタリング機能を追加する
    2-3.複数の音を同時に鳴らす
  3. 外部から音を取り込む
    3-1.単音の音源サンプルから音を取り込む(1楽器)
    3-2.音源から楽器を取り出す
  4. 機能を追加する
    4-1.録音&再生する(パターンをつくる)
    4-2.外部と連携する
  5. 曲を演奏する
  6. 曲を作る

☑︎本記事の内容

  • 必要な材料:Tkinterで画面をつくる
  • レシピ①:静的なグラフを画面に配置する
  • レシピ②:動的なグラフを画面に配置する(キープレスに応じて波形を変化させる)

☑︎著者の経験

この記事を書いている私は、非情報系の大学院を卒業後、通信関係の企業に3年勤めた後、現在までコンサルティング会社に勤めています。
学生時代〜現職までプログラミングとは縁がなく、前職でルーターの設定を少しかじった程度のプログラミング完全初心者です。
こういった私が、解説していきます。

Twitterもはじめました。

必要な材料:GUI部分の作成

○ライブラリ

今回使用するライブラリは以下です。

使用したライブラリ

  • numpy:数値演算用ライブラリ → sin波を作る
  • PyAudio:オーディオ関連ライブラリ → 音を鳴らす
  • matplotlib:グラフ作成ライブラリ → 波形をプロットする
  • Tkinter:GUI作成ライブラリ → 画面をつくる

レシピ①:静的なグラフを画面に配置する

改めて、作りたい画面イメージは以下です。

作りたい画面イメージはこちら

前の記事でTkinterに静的なグラフを表示させることはできました。

今回やりたいことは以前の記事であげたとおり、sin波およびゲインを画面上にプロットすることです。そして、 sin波をキープレスに応じて動的に変化させたい と考えています。
※なお、ゲインの制御は、ステップ2. 音を制御するで検討するため、ステップ1では一旦ゲインの変化はなしで進めます。

とはいえ、まずはTkinterの基本的な構造を確かめるために、静的なグラフを画面に表示してみようと思います。

○参考コードの確認

1からコードを書く前に、参考にしていたGitHubのコードから、sin波以外の部分を削ぎ落として今回のポイントを確認してみました。

GitHubのリンクはこちら

参考コード(synth_gui.py)の実行結果

コードはこちら

# === ライブラリのインポート
import tkinter as tk
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
import numpy as np

# === class定義
class Section(tk.Tk):  # section.pyをコピー
    def __init__(self, parent, headline, fg, bg):
        self.border = tk.LabelFrame(parent, text=headline, fg=fg, bg=bg, relief=tk.FLAT)
        self.innerBox = None
        self.section = None
        self.row = 0
        self.column = 0

    def setPosition(self, row, column, rowspan, columnspan, padx, pady):
        self.border.grid(row=row, column=column, rowspan=rowspan, columnspan=columnspan, padx=padx, pady=pady, sticky=tk.N + tk.W)
        self.innerBox = tk.Frame(self.border)
        self.innerBox.grid()
        self.section = self.innerBox

    def getSection(self):
        return self.section


# === 画面の描画
root = tk.Tk()   # Tk() はクラス Tk のインスタンス (オブジェクト) を生成して返す
root.title("Synth_Application")
root.resizable(width=False, height=False)  # ウィンドウサイズ全体がフリーズ
winWidth = 700
winHeight = 570
screenWidth = root.winfo_screenwidth()
screenHeight = root.winfo_screenheight()
startX = int((screenWidth / 2) - (winWidth / 2))
startY = int((screenHeight / 2) - (winHeight / 2))
root.geometry('{}x{}+{}+{}'.format(winWidth, winHeight, startX, startY))

# === 配置
FIRST = 0
SECOND = FIRST + 1

PAD_X = 20
PAD_Y = 20


# === 波形 section
sectionChunk = Section(root, "WaveForm", 'white', '#444')
sectionChunk.setPosition(SECOND, FIRST, 1, 1, PAD_X, PAD_Y)

# == 波形の式
x = np.arange(0, 1024) / 44100  # なぜ1024?
envRange = np.ones(1024)  # 1字配列 10241分割?
y = np.sin(2 * np.pi * 261.262 * x)

# === Figure instance
fig = Figure(figsize=(6.5, 2), facecolor='#F0F0F0') # Figureを設定
#fig = plt.figure()

# Axesの追加
axes_x = fig.add_subplot(1, 2, 1)  # 1行2列の最初(1,1)に表示
axes_x.plot(x, envRange)

axes_y = fig.add_subplot(1, 2, 2)  # 1行2列の2番目(1,2)に表示
axes_y.plot(y[:500])

plt.show()

# Canvas
canvas = FigureCanvasTkAgg(fig, master=sectionChunk.getSection())
#canvas._tkcanvas.grid(row=FIRST, column=FIRST, sticky=E + W)
canvas.draw()
canvas.get_tk_widget().pack()

root.mainloop()

上記から、そのまま流用できるところと改良が必要なところを洗い出してみました。

そのまま流用できるところ
  • 画面をPCの真ん中に表示させている
  • plt.figureじゃなくてFigureを使う

これらについては、すぐにわかったので以下、メモに残します。

メモはこちら

>画面をPCの真ん中に表示させている

下記でメインウィンドウのサイズは定義し、モニターのサイズは取得しています。

winWidth = 700 # メインウィンドウの幅を定義
winHeight = 570 # メインウィンドウの高さを定義

screenWidth = root.winfo_screenwidth() # 使用しているモニターの横幅を取得
screenHeight = root.winfo_screenheight() # 使用しているモニターの縦幅を取得

下記で画面の中心にメインウィンドウがくるようにメインウィンドウの左上の位置を定義しています。

startX = int((screenWidth / 2) - (winWidth / 2))
startY = int((screenHeight/ 2) - (winHeight / 2))

下記はf-strings型の記述ですが、 {A}x{B}+{X}+{Y}でサイズ(A✕B)のメインウィンドウを位置(X, Y)に配置する となります。

root.geometry('{}x{}+{}+{}'.format(winWidth, winHeight, startX, startY))  # f-strings 700px × 570pxの画面が左上(縦Xpx、横Ypx) 

>plt.figureじゃなくてFigureを使う

Figureインスタンスの配置にこれまでfig = plt.figure()を使っていましたが、参考コードではFigureをライブラリからインポートし、fig=Figure()という記載を用いていました。

# === ライブラリのインポート
from matplotlib.figure import Figure
…

# === Figure instance
fig = Figure(figsize=(6.5, 2), facecolor='#F0F0F0') # Figureを設定
#fig = plt.figure() # これまでの書き方

あまり違いはないのかなと思いましたが、plt.figure()を使うと、下記のように実行時にTkinter画面外のコード実行環境にも配置されています。今回は必要ないと感じていましたが、 Figureを()を使うとTkinter画面外にプロットされなくなりました。 

fig=plt.figure()の場合

改良が必要なところ、よくわからないところ
  • 配置がずれているため、真ん中に配置したい
  • classの範囲がよくわからない
  • classの因数にtk.Frameが含まれている理由がわからない

これらを考えるうえで、方針として、

  1. classなしで配置を整える
  2. class化する

で考えていくことにしました。

○結果①:配置を整える(classなし)

以下のコードで波形のグラフ配置を整えることができました。

ポイントは、前回の記事に沿って、メインウィンドウにいきなりグラフのウィジェットを配置するのではなく、①ウィジェットごとに配置を整えられうようにフレームを配置し、②その上にグラフウィジェット用のフレーム(FrameとLabelFrameを組み合わせたもの)を配置し、③その上にグラフウィジェットを配置してる点です。

①と②の違いは、①はメインウィンドウの整形用で、今回は見える形にしていますが見えなくてもよいかなと思っています。一方、②は見栄えをよくするためのフレームです。

コードはこちら

import numpy as np
import matplotlib.pyplot as plt
import tkinter as tk
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure

# 1. メインウィンドウの作成
root = tk.Tk()
root.title("Synth_Application")
winWidth = 700 # メインウィンドウの幅を定義
winHeight = 570 # メインウィンドウの高さを定義
#windowSize = str(winHeight) + 'x' + str(winHeight)
root.resizable(width=False,height=False) #ウィンドウ幅の固定

#=== オプション:メインウィンドウを画面ん中央に表示させる
screenWidth = root.winfo_screenwidth() # 使用しているモニターの横幅を取得
screenHeight = root.winfo_screenheight() # 使用しているモニターの縦幅を取得
startX = int((screenWidth / 2) - (winWidth / 2))
startY = int((screenHeight / 2) - (winHeight / 2))
root.geometry('{}x{}+{}+{}'.format(winWidth, winHeight, startX, startY))  # f-strings 700px × 570pxの画面が左上(縦Xpx、横Ypx)

#=== オプション:フレームをつくる
frame = tk.Frame(root, width=700, height=300, bg="#000080")
frame.grid(column=0, row=0) # 1行1列に配置
frame.grid_propagate(0) # フレーム幅の固定
frame.grid_anchor(tk.CENTER) # gridを中央に表示するには、grid()で配置する要素の親要素にgrid_anchor(tkinter.CENTER)を使う

#=== オプション:ラベルフレームウィジットをつくる
border = tk.LabelFrame(frame, text="WaveForm", fg='white', bg='#444', relief=tk.FLAT)
border.grid(column=0, row=0)
innerBox = tk.Frame(border)
innerBox.grid()

# 2. Matplotlibライブラリを利用して、グラフを作成する
#=== 2-1.Figureインスタンスをつくる
fig = Figure(figsize=(6.5,2), dpi=100, tight_layout=True, facecolor='#F0F0F0') ## 650 x 200のFigureインスタンスを作成, tight_layoutでオブジェクトの配置を自動調整


#=== 2-2.実際のグラフを描画するAxesインスタンスをつくる
axes_x = fig.add_subplot(1, 2, 1)  # 1行2列の最初(1,1)に表示
axes_x.set_title('Axes_x')
axes_x.set_xlabel('x', fontsize=8) # ラベルのオプション
axes_x.set_ylabel('envRange', fontsize=8) # ラベルのオプション

axes_y = fig.add_subplot(1, 2, 2)  # 1行2列の2番目(1,2)に表示
axes_y.set_title('Axes_y')
axes_y.set_xlabel('x', fontsize=8) # ラベルのオプション
axes_y.set_ylabel('y', fontsize=8) # ラベルのオプション


#=== 2-3.Figureインスタンス上にグラフをプロットする
x = np.arange(0, 1024) / 44100  # なぜ1024?
envRange = np.ones(1024)  # 1字配列 10241分割?
y = np.sin(2 * np.pi * 261.262 * x)

line_x, = axes_x.plot(x, envRange)
axes_x.tick_params(labelsize=6) # 軸のオプション
line_y, = axes_y.plot(y[:500])
axes_y.tick_params(labelsize=6) # 軸のオプション


# 3. FigureCanvasTkAggを宣言し、Frameウィジェット上に配置する
canvas = FigureCanvasTkAgg(fig, master=innerBox)

# 4. グラフをTkinterのウィジェットとして表示させる
canvas.get_tk_widget().pack(padx=10, pady=10)

root.mainloop()

以下、メモです。

ウィジェットを配置するときはウィジェットの第1引数に子ウィジェットを指定し、親ウィジェットと子ウィジェットの関係を示します。たとえば、メインウィンドウにframを配置するときは、frame=tk.Frame(root, …)となり、frameウィジェットにborderウィジェットを配置するときは、border=tk.LabelFrame(frame, …)のような感じです。

# 1. メインウィンドウの作成
root = tk.Tk()
…

#=== オプション:フレームをつくる
frame = tk.Frame(root, …)
frame.grid(column=0, row=0) # 1行1列に配置
…

#=== オプション:ラベルフレームウィジットをつくる
border = tk.LabelFrame(frame, …)
border.grid(column=0, row=0)
innerBox = tk.Frame(border)
innerBox.grid()

# 2. Matplotlibライブラリを利用して、グラフを作成する
#=== 2-1.Figureインスタンスをつくる
fig = Figure(…)

# 3. FigureCanvasTkAggを宣言し、Frameウィジェット上に配置する
canvas = FigureCanvasTkAgg(fig, master=innerBox)

…

○課題と解決方法

.grid()を使って”frame = tk.Frame()”の中心に”border = tk.LabelFrame()”を表示させようと思いしたが、tk.LabelFrameの親ウィジットに”frame”を指定するとなぜかLabelFrameが左上に配置され、オプションのanchorなどで位置を指定しても変わりませんでした。また、ネイビー色に設定したはずのframeも消えてしまいました。

失敗結果

コードはこちら

import numpy as np
import matplotlib.pyplot as plt
import tkinter as tk
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg

# 1. メインウィンドウの作成
root = tk.Tk()
root.title("Synth_Application")
winWidth = 700 # メインウィンドウの幅を定義
winHeight = 570 # メインウィンドウの高さを定義
#windowSize = str(winHeight) + 'x' + str(winHeight)
root.resizable(width=False,height=False) #ウィンドウ幅の固定

#=== オプション:メインウィンドウを画面ん中央に表示させる
screenWidth = root.winfo_screenwidth() # 使用しているモニターの横幅を取得
screenHeight = root.winfo_screenheight() # 使用しているモニターの縦幅を取得
startX = int((screenWidth / 2) - (winWidth / 2))
startY = int((screenHeight / 2) - (winHeight / 2))
root.geometry('{}x{}+{}+{}'.format(winWidth, winHeight, startX, startY))  # f-strings 700px × 570pxの画面が左上(縦Xpx、横Ypx)

#=== オプション:フレームをつくる
frame = tk.Frame(root, width=700, height=300, bg="#000080")
frame.grid(column=0, row=0) # 1行1列に配置

#=== オプション:ラベルフレームウィジットをつくる
border = tk.LabelFrame(frame, text="WaveForm", fg='white', bg='#444', relief=tk.FLAT)
border.grid(column=0, row=0)
innerBox = tk.Frame(border)
innerBox.grid()

# 2. Matplotlibライブラリを利用して、グラフを作成する
#=== 2-1.Figureインスタンスをつくる
fig = plt.figure(figsize=(6.5,2), dpi=100, tight_layout=True, facecolor='#F0F0F0') ## 650 x 200のFigureインスタンスを作成, tight_layoutでオブジェクトの配置を自動調整


#=== 2-2.実際のグラフを描画するAxesインスタンスをつくる
axes_x = fig.add_subplot(1, 2, 1)  # 1行2列の最初(1,1)に表示
axes_x.set_title('Axes_x')
axes_x.set_xlabel('x', fontsize=8) # ラベルのオプション
axes_x.set_ylabel('envRange', fontsize=8) # ラベルのオプション

axes_y = fig.add_subplot(1, 2, 2)  # 1行2列の2番目(1,2)に表示
axes_y.set_title('Axes_y')
axes_y.set_xlabel('x', fontsize=8) # ラベルのオプション
axes_y.set_ylabel('y', fontsize=8) # ラベルのオプション


#=== 2-3.Figureインスタンス上にグラフをプロットする
x = np.arange(0, 1024) / 44100  # なぜ1024?
envRange = np.ones(1024)  # 1字配列 10241分割?
y = np.sin(2 * np.pi * 261.262 * x)

line_x, = axes_x.plot(x, envRange)
axes_x.tick_params(labelsize=6) # 軸のオプション
line_y, = axes_y.plot(y[:500])
axes_y.tick_params(labelsize=6) # 軸のオプション


# 3. FigureCanvasTkAggを宣言し、Frameウィジェット上に配置する
canvas = FigureCanvasTkAgg(fig, master=innerBox)

# 4. グラフをTkinterのウィジェットとして表示させる
canvas.get_tk_widget().pack(padx=10, pady=10)

root.mainloop()

課題

  1. frameの枠が消える
  2. borderがメインウィンドウの左上に表示される

これを解決するのにとても時間を費やしました。。。
重要なポイントは、  gridのサイズは子要素に合わせられるということです。 

今回の階層構造(親要素と子要素)は以下となっています。

階層構造

  • root         ・・・第1階層
    • frame      ・・・第2階層
      • border   ・・・第3階層
        • innerBox・・・第4階層

このとき、borderのサイズは子要素innerBoxに合わさった状態となり、innerBoxは親要素borderにgridで配置されます。同様にframeのサイズは子要素borderに合わさった状態となり、borderは親要素frameにgridで配置されます。

これらの親要素と子要素の関係、そしてサイズは子要素に合わせられることからから、結果的にborderがメインウィンドウの左上に配置されたように見えたようです。

これを解消する方法として、以下の手順が必要です。

  1. frameの枠が消える →  第2階層frameのフレームサイズを固定する 
  2. borderがメインウィンドウの左上に表示される →  第3階層borderのgrid配置位置を第2階層frameで定義する 

これを説明するにあたり、簡単な例を考えました。

階層構造

  • root         ・・・第1階層(メインウィンドウ)
    • frame1       ・・・第2階層(赤色フレーム)
      • frame2   ・・・第3階層(青色フレーム)

まず、単純にそれぞれを配置すると以下のように、第2階層の赤色フレームが見えず、第3階層の青色フレームが左上に配置されます。

コードはこちら

import tkinter as tk

root = tk.Tk()
root.title("Application")
root.resizable(width=False,height=False)
root.geometry('700x500')

frame1 = tk.Frame(root, width=700, height=300, bg="red")
frame1.grid()

frame2 = tk.Frame(frame1, width=300, height=200, bg='blue')
frame2.grid(row = 0)

root.mainloop()

次に、第2階層frame1のフレームサイズをgrid_propagate(0)で固定します。

frame1.grid_propagate(0) # フレーム幅の固定

すると、第2階層が見えるようになりました。しかし、第3階層は右上のままです。

コードはこちら

import tkinter as tk

root = tk.Tk()
root.title("Application")
root.resizable(width=False,height=False)
root.geometry('700x500')

frame1 = tk.Frame(root, width=700, height=300, bg="red")
frame1.grid()
frame1.grid_propagate(0) # フレーム幅の固定

frame2 = tk.Frame(frame1, width=300, height=200, bg='blue')
frame2.grid(row = 0)

root.mainloop()

最後に、第3階層frame2のgrid配置位置を調整します。gridを中央に表示するには、grid()で配置する要素の親要素にgrid_anchor(tkinter.CENTER)を使います。

frame1.grid_anchor(tk.CENTER) # gridを中央に表示するには、grid()で配置する要素の親要

すると、第3階層が第2階層の中央に配置されました。

コードはこちら

import tkinter as tk

root = tk.Tk()
root.title("Application")
root.resizable(width=False,height=False)
root.geometry('700x500')

frame1 = tk.Frame(root, width=700, height=300, bg="red")
frame1.grid()
frame1.grid_propagate(0) # フレーム幅の固定
frame1.grid_anchor(tk.CENTER) # # gridを中央に表示するには、grid()で配置する要素の親要素にgrid_anchor(tkinter.CENTER)を使う

frame2 = tk.Frame(frame1, width=300, height=200, bg='blue')
frame2.grid(row = 0)

root.mainloop()

○結果②:配置を整える(class化)

classを用いずに静的なグラフをいい感じに配置することができました。次にこれをclass化しました。classについて前記事で記載しましたが、正直ふんわりしかわかってませんので、確かめながらやりました。

まず、メインウィンドウ部分だけクラス”Application”としてclass化してみました。

結果はこちら

import tkinter as tk
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg

#=== アプリケーションの定義
class Application(tk.Frame):
    #=== 初期設定
    def __init__(self, master=None):
        #=== メインウィンドウの設定
        super().__init__(master) # 親クラス(Application)の __init__ メソッドを実行
        self.master = master
        self.master.title('Synth_Application')
        self.master.resizable(width=False,height=False) #ウィンドウ幅の固定

        #=== オプション:メインウィンドウを画面ん中央に表示させる
        winWidth = 700 # メインウィンドウの幅を定義
        winHeight = 570 # メインウィンドウの高さを定義
        screenWidth = self.master.winfo_screenwidth() # 使用しているモニターの横幅を取得
        screenHeight = self.master.winfo_screenheight() # 使用しているモニターの縦幅を取得
        startX = int((screenWidth / 2) - (winWidth / 2))
        startY = int((screenHeight / 2) - (winHeight / 2))
        self.master.geometry('{}x{}+{}+{}'.format(winWidth, winHeight, startX, startY))  # f-strings 700px × 570pxの画面が左上(縦Xpx、横Ypx)



root = tk.Tk()
app = Application(master=root)
app.mainloop()

うまくいったので、create_widgets()メソッドを定義しました。こちらもうまくいきました。
__init__()内でにself.create_widgets()を記述することで、class実装時にcreate_widgets()メソッドが実装されます。

コードはこちら

import tkinter as tk
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure

#=== アプリケーションの定義
class Application(tk.Frame):
    #=== 初期設定
    def __init__(self, master=None):
        #=== メインウィンドウの設定
        super().__init__(master) # 親クラス(Application)の __init__ メソッドを実行
        self.master = master
        self.master.title('Synth_Application')
        self.master.resizable(width=False,height=False) #ウィンドウ幅の固定

        #=== オプション:メインウィンドウを画面ん中央に表示させる
        winWidth = 700 # メインウィンドウの幅を定義
        winHeight = 570 # メインウィンドウの高さを定義
        screenWidth = self.master.winfo_screenwidth() # 使用しているモニターの横幅を取得
        screenHeight = self.master.winfo_screenheight() # 使用しているモニターの縦幅を取得
        startX = int((screenWidth / 2) - (winWidth / 2))
        startY = int((screenHeight / 2) - (winHeight / 2))
        self.master.geometry('{}x{}+{}+{}'.format(winWidth, winHeight, startX, startY))  # f-strings 700px × 570pxの画面が左上(縦Xpx、横Ypx)

        #=== ウィジットをつくる → def create_widgets
        self.create_widgets()       
        
    #=== ウィジットの定義
    def create_widgets(self):
        #=== フレームをつくる
        self.canvas_frame = tk.Frame(self.master, width=700, height=250, bg="#000080")
        self.canvas_frame.grid(column=0, row=0) # 1行1列に配置
        self.canvas_frame.grid_propagate(0) # フレーム幅の固定
        self.canvas_frame.grid_anchor(tk.CENTER) # gridを中央に表示するには、grid()で配置する要素の親要素にgrid_anchor(tkinter.CENTER)を使う

        #=== ウィジェット:LabelFrameウィジットをつくる
        self.chunk_labelFrame = tk.LabelFrame(self.canvas_frame, text="WaveForm", fg='white', bg='#444', relief=tk.FLAT)
        self.chunk_labelFrame.grid(column=0, row=0)
        self.chunk_innerBox = tk.Frame(self.chunk_labelFrame)
        self.chunk_innerBox.grid(column=0, row=0)

        #=== Figureインスタンスをつくる
        fig = Figure(figsize=(6.5,2), dpi=100, tight_layout=True, facecolor='#F0F0F0') ## 650 x 200のFigureインスタンスを作成, tight_layoutでオブジェクトの配置を自動調整

        axes_x = fig.add_subplot(1, 2, 1)  # 1行2列の最初(1,1)に表示
        axes_x.set_title('Axes_x')
        axes_x.set_xlabel('x', fontsize=8) # ラベルのオプション
        axes_x.set_ylabel('envRange', fontsize=8) # ラベルのオプション

        axes_y = fig.add_subplot(1, 2, 2)  # 1行2列の2番目(1,2)に表示
        axes_y.set_title('Axes_y')
        axes_y.set_xlabel('x', fontsize=8) # ラベルのオプション
        axes_y.set_ylabel('y', fontsize=8) # ラベルのオプション
        
        x = np.arange(0, 1024) / 44100  # なぜ1024?
        envRange = np.ones(1024)  # 1字配列 10241分割?
        y = np.sin(2 * np.pi * 261.262 * x)

        line_x, = axes_x.plot(x, envRange)
        axes_x.tick_params(labelsize=6) # 軸のオプション
        line_y, = axes_y.plot(y[:500])
        axes_y.tick_params(labelsize=6) # 軸のオプション
        
        #=== ウィジェット:FigureCanvasTkAggを宣言し、LabelFrameウィジェット上に配置する
        self.canvas = FigureCanvasTkAgg(fig, master=self.chunk_innerBox)
        self.canvas.get_tk_widget().pack(padx=10, pady=10)


root = tk.Tk()
app = Application(master=root)
app.mainloop()

○課題と解決方法

Applicationの引数にtk.Frameを入れないとTypeErrorになりました。

TypeError: object.__init__() takes exactly one argument (the instance to initialize)

調べたところ、“class Application(tk.Frame)”の様な書き方の場合、 「継承」 と言い、tk.Frameを渡すことで、Frameオブジェクトを継承しているようです。実際にはメインウィンドウ用のインスタンスを作成したあと、そのオブジェクトをrootに渡すことになります。

正直まだあまりピンときてませんが、とりあえず入れるものという理解でいきたいと思います。(詳しくわかったら追記します。)

動的なグラフを画面に配置する(キープレスに応じて波形を変化させる)

静的なグラフを表示できたので、いよいよキープレスに応じて音を変化させるアプリに応用させます。

○キー入力とバインド

前の記事で、Tkinterはイベント駆動式のライブラリであることを記述しました。つまり、何かしらの処理(イベント)が起こると、そのイベントをきっかけにプログラムが実行されることを言います。

Tkinterの動作をもう一度記述します。

  1. 初期化
  2. イベント取得
  3. イベントに応じた処理の振り分け
  4. 後続プログラム実行
  5. 2 に戻る

今回新しくコードに加えるのは、2~4のところです。これに必要なのがバインディングとイベントハンドラです。

  • バインディング:振り分け時の処理機能(上記の2,3に対応) →  bind()メソッド 
  • イベントハンドラ:バインディングによって実行される後続プラグラム(上記の4) →  コールバック関数 

今回、イベントはキー入力になります。参考になる記事があったので、まずは簡単なコードで試してみたいと思います。

(参考)キー入力とバインド – Python/Tkinter プログラミング

キープレスを検出し、入力されたキーを画面に表示されるコードを書きました。結果は以下です。

import tkinter as tk
from tkinter import *

# メインウィンドウ
root = tk.Tk()
root.option_add('*font', ('FixedSys', 14)) # オプション:フォント、文字サイズ

# 式を格納するオブジェクト
buffer = StringVar() # "from tkinter import * " が必要
buffer.set('')

# キーの表示
def print_key(event): # イベントハンドラ(コールバック関数)
    key = event.keysym
    buffer.set('push key is %s' % key)

# ラベルの設定
Label(root, text = '*** push any key ***').pack()
a = Label(root, textvariable = buffer)
a.pack()
a.bind('<Any-KeyPress>', print_key) # バインディング
a.focus_set()

root.mainloop()

bind()メソッドは以下の構文で記述します。

 widget.bind(eventsequence, callback) 

eventsequenceのイベント情報を受け、callbackに記述された後続のプログラムが実行されるという意味です。

また、イベントの指定(eventsequence)は次のような構文を持っています。

 <modifier-modifier-type-detail> 

キープレス検出の一部を紹介します。

  • <KeyPress-a>:キー”a”が入力されたときのイベント
  • <Button-1>:キー”1”が入力されたときのイベント ※数字キーはKeyPressではなくButtonを使う
  • <Control-d> :コントロールキーと d キーを同時に入力されたときのイベント

この情報をもとに、キープレスを検出し、入力されたキーによって音を変えるようにしたいと思います。

○結果

以下のコードで、入力キーによって出力される音が変わり、更に画面の波形も変化しました。
 ただし、波形の変化と音の再生の間にラグが生じています。 

コードはこちら

# ライブラリのインポート
import tkinter as tk
import numpy as np  # sin波
import pyaudio  # メモリ上の音楽を再生
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure

#=== アプリケーションの定義
class Synth_application(tk.Frame):
    #=== 初期設定
    def __init__(self, master=None):
        #=== メインウィンドウの設定
        super().__init__(master) # 親クラス(Application)の __init__ メソッドを実行
        self.master = master
        self.master.title('Synth_Application')
        self.master.resizable(width=False,height=False) #ウィンドウ幅の固定

        #=== オプション:メインウィンドウを画面ん中央に表示させる
        winWidth = 700 # メインウィンドウの幅を定義
        winHeight = 570 # メインウィンドウの高さを定義
        screenWidth = self.master.winfo_screenwidth() # 使用しているモニターの横幅を取得
        screenHeight = self.master.winfo_screenheight() # 使用しているモニターの縦幅を取得
        startX = int((screenWidth / 2) - (winWidth / 2))
        startY = int((screenHeight / 2) - (winHeight / 2))
        self.master.geometry('{}x{}+{}+{}'.format(winWidth, winHeight, startX, startY))  # f-strings 700px × 570pxの画面が左上(縦Xpx、横Ypx)

        #=== sin波の初期値設定 → def start_up
        self.start_up() # 初期値
        
        #=== ウィジットをつくる → def create_widgets
        self.create_widgets()
        
        #=== pyaudioの開始             
        self.p = pyaudio.PyAudio() 

        
    #=== 初期値
    def start_up(self, gain=0.25, rate=44100, chunk_size=1024):
        global duration # 初期値のためにglobal変数が必要
        duration = DURATION['L4']
        
        self.gain = gain  # "A"
        self.rate = rate  # サンプリング周波数"fs":44100
        self.chunk_size = chunk_size  # 音源から1回読み込むときのデータサイズ。1024(=2の10乗) とする場合が多い

        self.t = np.arange(0, duration * self.rate) / self.rate
        self.envRange = np.ones(int(duration * self.rate))  # 1字配列
        self.sin_t = self.gain * np.sin(2 * np.pi * 0 * self.t)
        
    #=== ウィジットの定義
    def create_widgets(self):
        #=== フレームをつくる
        self.canvas_frame = tk.Frame(self.master, width=700, height=250, bg="#000080")
        self.canvas_frame.grid(column=0, row=0) # 1行1列に配置
        self.canvas_frame.grid_propagate(0) # フレーム幅の固定
        self.canvas_frame.grid_anchor(tk.CENTER) # gridを中央に表示するには、grid()で配置する要素の親要素にgrid_anchor(tkinter.CENTER)を使う

        #=== ウィジェット:LabelFrameウィジットをつくる
        self.chunk_labelFrame = tk.LabelFrame(self.canvas_frame, text="WaveForm", fg='white', bg='#444', relief=tk.FLAT)
        self.chunk_labelFrame.grid(column=0, row=0)
        self.chunk_innerBox = tk.Frame(self.chunk_labelFrame)
        self.chunk_innerBox.grid(column=0, row=0)

        #=== Figureインスタンスをつくる
        fig = Figure(figsize=(6.5,2), dpi=100, tight_layout=True, facecolor='#F0F0F0') ## 650 x 200のFigureインスタンスを作成, tight_layoutでオブジェクトの配置を自動調整

        axes_envRange = fig.add_subplot(1, 2, 1)  # 1行2列の最初(1,1)に表示
        axes_envRange.set_title('envRange')
        axes_envRange.set_xlabel('t', fontsize=8) # ラベルのオプション
        axes_envRange.set_ylabel('envRange', fontsize=8) # ラベルのオプション

        axes_wave = fig.add_subplot(1, 2, 2)  # 1行2列の2番目(1,2)に表示
        axes_wave.set_title('Sin_wave')
        axes_wave.set_xlabel('t', fontsize=8) # ラベルのオプション
        axes_wave.set_ylabel('sin_t', fontsize=8) # ラベルのオプション
        axes_wave.set_ylim([-(self.gain + 0.05), (self.gain + 0.05)])

        self.line_envRange, = axes_envRange.plot(self.t, self.envRange)
        self.line_wave, = axes_wave.plot(self.sin_t[:500])       
        
        #=== ウィジェット:FigureCanvasTkAggを宣言し、LabelFrameウィジェット上に配置する
        self.canvas = FigureCanvasTkAgg(fig, master=self.chunk_innerBox)
        self.canvas.get_tk_widget().pack(padx=10, pady=10)
        self.canvas.get_tk_widget().bind('<Any-KeyPress>', self.press_key) # イベントハンドラと連動
        
    # sin波の作成
    def create_sinwave(self, duration, freq):
        self.duration = duration
        self.freq = freq
        
        # 指定周波数のsin波を指定秒数生成
        self.t = np.arange(0, duration * self.rate) / self.rate
        self.envRange = np.ones(int(duration * self.rate))  # 1字配列
        self.sin_t = self.gain * np.sin(2 * np.pi * freq * self.t)

    def press_key(self, event):
        global duration, freq # 初期値として、create_sinwave(DURATION['L4'], FREQ_SCALE['ド/C4'])のために必要
        self.key = event.keysym
        if self.key == 'j':
            duration = DURATION['L8']
        elif self.key == 'k':
            duration = DURATION['L4']
        elif self.key == 'l':
            duration = DURATION['L2']
        elif self.key == ';':
            duration = DURATION['L1']

        if self.key == 'a':
            freq = FREQ_SCALE['ド/C4']
            self.create_sinwave(duration, freq)   # def create_sinwave()に渡す
            self.play()
        elif self.key == 's':
            freq = FREQ_SCALE['レ/D4']
            self.create_sinwave(duration, freq)   # def create_sinwave()に渡す
            self.play()
        elif self.key == 'd':
            freq = FREQ_SCALE['ミ/E4']
            self.create_sinwave(duration, freq)   # def create_sinwave()に渡す
            self.play()
        elif self.key == 'f':
            freq = FREQ_SCALE['ファ/F4']
            self.create_sinwave(duration, freq)   # def create_sinwave()に渡す
            self.play()
        elif self.key == 'w':
            freq = FREQ_SCALE['ソ/G4']
            self.create_sinwave(duration, freq)   # def create_sinwave()に渡す
            self.play()
        elif self.key == 'e':
            freq = FREQ_SCALE['ラ/A4']
            self.create_sinwave(duration, freq)   # def create_sinwave()に渡す
            self.play()
        elif self.key == 'r':
            freq = FREQ_SCALE['シ/B4']
            self.create_sinwave(duration, freq)   # def create_sinwave()に渡す
            self.play()
        elif self.key == 'A':
            freq = FREQ_SCALE['ド/C5']
            self.create_sinwave(duration, freq)   # def create_sinwave()に渡す
            self.play()
        elif self.key == 's':
            freq = FREQ_SCALE['レ/D4']
            self.create_sinwave(duration, freq)   # def create_sinwave()に渡す
            self.play()
            
        self.line_wave.set_ydata(self.sin_t[:500])      # グラフのy軸をアップデート
        self.canvas.draw()                    # グラフのアップデート

    # ストリームに渡して再生
    def play(self):
        self.stream = self.p.open(format=pyaudio.paFloat32,
                                  channels=1,
                                  rate=self.rate,
                                  frames_per_buffer=self.chunk_size,
                                  output=True)
        self.stream.write(self.sin_t.astype(np.float32).tobytes())
        self.stream.close()


# パラメータ
bpm = int(input('BPMを入力してください : '))
DURATION = {
    'L1': (60 / bpm * 4),      # 全音符
    'L2': (60 / bpm * 4) / 2,  # 二分音符
    'L4': (60 / bpm * 4) / 4,  # 四分音符
    'L8': (60 / bpm * 4) / 8,  # 八分音符
}
FREQ_SCALE = {  # 周波数f0のスケール
    'ド/C4': 261.626,
    'レ/D4': 293.665,
    'ミ/E4': 329.628,
    'ファ/F4': 349.228,
    'ファ#/F#4': 369.994,
    'ソ/G4': 391.995,
    'ソ#/G#4': 415.305,
    'ラ/A4': 440.000,
    'ラ#/A#4': 466.164,
    'シ/B4': 493.883,
    'ド/C5': 523.251,
    'レ/D5': 587.330,
    'ミ/E5': 659.255,
}


root = tk.Tk()
app = Synth_application(master=root)
app.mainloop()

以下、メモです。

  • self.canvas.get_tk_widget().bind(‘<Any-KeyPress>’, self.press_key)がイベントハンドラで、何らかのキープレスがあった際に、コールバックself.press_keyに続きます。
  • def press_key(self, event)がコールバック関数です。注意点として、 コールバック関数はイベントハンドラの後続処理なので、__init__()に記述は必要ありません。 
  • self.key = event.keysymとしたあと、if self.key == ‘a’のような書き方をすることで、押されたキーを指定することができます。
  • self.line_wave.set_ydata(self.sin_t[:500])とself.canvas.draw()がないとグラフがアップデートされません。
#=== アプリケーションの定義
class Synth_application(tk.Frame):
    #=== 初期設定
    def __init__(self, master=None):
…
        
    #=== ウィジットの定義
    def create_widgets(self):
…             
        #=== ウィジェット:FigureCanvasTkAggを宣言し、LabelFrameウィジェット上に配置する
        self.canvas = FigureCanvasTkAgg(fig, master=self.chunk_innerBox)
        self.canvas.get_tk_widget().pack(padx=10, pady=10)
        self.canvas.get_tk_widget().bind('<Any-KeyPress>', self.press_key) # イベントハンドラ
…

    def press_key(self, event): # コールバック関数
        global duration, freq # 初期値として、create_sinwave(DURATION['L4'], FREQ_SCALE['ド/C4'])のために必要
        self.key = event.keysym
…

        if self.key == 'a':
            freq = FREQ_SCALE['ド/C4']
            self.create_sinwave(duration, freq)   # def create_sinwave()に渡す
            self.play()
…            
        self.line_wave.set_ydata(self.sin_t[:500])      # グラフのy軸をアップデート
        self.canvas.draw()                    # グラフのアップデート

 

○課題と解決方法

class Synth_application内のcreate_sinwave()メソッドにて、波の式を書きとしたところエラーとなりました。

        # 指定周波数のsin波を指定秒数生成
        t = np.arange(0, duration * self.rate) / self.rate
        envRange = np.ones(int(duration * self.rate))  # 1字配列
        sin_t = self.gain * np.sin(2 * np.pi * freq * t)

NameError: name ‘sin_t’ is not defined

一方、create_sinwave()内のtやsin_tに”self.”をつけるとエラーが無くなりました。
ここで、sin_tはcreate_sinwave()のアトリビュート(引数)ではない認識でした。そのため、app = Synth_application()で実装したあと、create_sinwave()メソッドとplay()の順に実行できると思ったところ、creat_sinwave()メソッドで定義しているsint_tがplay()メソッド実行時にNameErrorになりました。

class内の関数(メソッド)のどのような場合に ”self.”が必要か不要かわからなくなりました。 

○クラス内の変数

まず、公式ドキュメント読んでみました。が、まだよくわからなかったので、実際に色々試してみました。

(参考)公式ドキュメント チュートリアル 9.3.5. クラスとインスタンス変数

(参考)Pythonで、グローバル変数、インスタンス変数、ローカル変数 …

第一に、クラス直下とコンストラクタの変数です。

class Animal:

    kind = 'dog'         # class variable shared by all instances

    def __init__(self, name):
        self.name = name    # instance variable unique to each instance
                
dog1 = Animal('Pochi')
dog2 = Animal('Taro')

###
print(dog1.kind)                  # shared by all animals
print(dog2.kind)                  # shared by all animals
print(dog1.name)                  # unique to dog1
print(dog2.name)                  # unique to dog2

結果は以下のとおりで、 クラス直下はすべてのインスタンスに適応され、コンストラクタではインスタンス固有になることがわかります。 

print(dog1.kind) → dog
print(dog2.kind) → dog
print(dog1.name) → Pochi
print(dog2.name) → Taro

また、クラス直下の変数はメソッド内でも使用できました。メソッドaaa()でself.kindとすることでクラス直下のメソッドを呼び出せることがわかります。

class Animal:

    kind = 'dog'         # class variable shared by all instances

    def __init__(self, name):
        self.name = name    # instance variable unique to each instance
        
    def aaa(self):
        print(self.kind)    
        
dog1 = Animal('Pochi')
dog2 = Animal('Taro')

###
dog1.aaa()
dog2.aaa()

dog1.aaa() → dog 
dog2.aaa() → dog

次にメソッドをいくつか用意し、メソッド間の動きを確かめます。

class Animal:

    kind = 'dog'         # class variable shared by all instances

    def __init__(self, name):
        self.name = name    # instance variable unique to each instance
        
    def aaa(self):
        print(self.kind)
            
    def bbb(self):
        xxx = self.name
        self.yyy = self.name
        print("xxx:", xxx)
        print("self.yyy:", self.yyy)
        
    def ccc(self):
#        zz1 = xxx # Pattern1
#        self.zz2 = xxx # Pattern2
#        zz3 = self.yyy # Pattern3
#        self.zz4 = self.yyy # Pattern4
#        print("zz1:" ,zz1)
#        print("self.zz2:" ,self.zz2)
#        print("zz3:" ,zz3)
#        print("self.zz4:" ,self.zz4)
            
dog1 = Animal('Pochi')

###
dog1.bbb()
dog1.ccc()

__init__()とメソッドbbb()

メソッドbbb()に__init__()内のnameを呼び出すコードを書きました。
ここで、”self.”の有無を比較したところ、どちらでもnameを呼び出すことができました。

xxx: Pochi
self.yyy: Pochi

メソッドbbb()とメソッドccc()

次に、メソッド間の変数のやりとりを確かめて見ました。パターンは以下の4つです。

①両方にself.をつけない
②呼び出す側(メソッドccc())のみself.をつける
③呼び出される側(メソッドbbb())のみself.をつける
④両方にself.をつける

結果は以下のとおりでした。

① → NameError: name ‘xxx’ is not defined
② → NameError: name ‘xxx’ is not defined
③ → zz3: Pochi
④ → self.zz4: Pochi

これらの結果からわかったことは以下です。

 self.がない場合:ローカル変数としてクラス内の他のメソッドに渡せない
self.がある場合:インスタンス変数としてクラス内の他のメソッドに渡せる
 

※クラス直下の変数もインスタンス変数としてメソッド内のself.と同じ

まとめ&次回予告

○まとめ

PCのキープレスによってsin波の周波数を変えたのち、画面上のグラフにプロットすることができました(レシピ②)。
ただし、グラフが変化してから音が鳴る形でラグが生じています。これは新たな課題として今後解決していこうと思います。

使用したライブラリ(レシピ②)

  • numpy
  • PyAudio
  • Tkinter
  • matplotlib

ポイント

  • レイアウト調整用にメインウィンドウ上にフレームを配置し、その上にウィジェットを配置しています。
  • イベントハンドラ”self.canvas.get_tk_widget().bind(‘<Any-KeyPress>’, self.press_key)”とコールバック関数“def press_key(self, event)”でキープレスがあった際に、その種類によって音を変えています。

○今日のエラーと解決方法

エラー

  1. エラーではありませんが、ウィジェットがメインウィンドウの左上に寄ってしまった。
  2. TypeError: object.init() takes exactly one argument (the instance to initialize)
  3. NameError: name ‘sin_t’ is not defined

解決方法

  1. gridのサイズは子要素に合わせられるため、フレームサイズをgrid_propagate(0)で固定し、フレーム内の配置場所をgrid_anchor()で指定することで解消しました。
frame1.grid_propagate(0) # フレーム幅の固定
frame1.grid_anchor(tk.CENTER) # gridを中央に表示するには、grid()で配置する要素の親要
  1. “class Application(tk.Frame)”のようにclassをtk.Frameに「継承」させることで解消しました。
  2. class内のアトリビュート(変数)に”self.”をつけることでメソッド間で変数を参照することができました。

○次回予告

ラグの修正もそうですが、せっかく画面ができたので次回はもう少し画面をカスタマイズしてみたいと思います。

  1. キーボード入力で音を鳴らす
    ☑1−1.1つの音を鳴らす
    ☑1-2.音階を鳴らす
    ☑1-3.音の長さを自由に変える
    1-4. 画面をつくる
    1-5.メトロノーム機能をつくる

Leave a Reply

Your email address will not be published. Required fields are marked *