Pythonでの音声信号処理 (3) 読み込んだ情報を可視化する その２

## やりたいこと
ファイルから読み込んだ音声データを見やすい形で表示すること。

## やってみる
グラフ描画部分をtkinterで塩梅よく造ってみる。

```Grp.py
# -*- coding: utf-8 -*-
import tkinter as tk
import numpy as np
import math

class Line:

    def __init__(self):
        self.width = 640 # 横サイズ
        self.height = 200 # 縦サイズ

        self.vals = np.array([]) # データ列
        self.rate  = 256 # サンプリングレート

        self.amp = 1 # 表示時の幅
        self.orderScale = 1 # 表示縮尺指定値
        self.scale = 1 # 表示縮尺

        self.cnv = None # キャンバス
        self.items = [] # 表示部品
        self.dispSt = 0 # 表示開始INDEX
        self.dispEd = 0 # 表示終了INDEX

    def setWidth(self, width):
        self.width = width

    def setHeight(self, height):
        self.height = height

    def setScale(self, scale):
        self.orderScale = scale
        self.scale = self.orderScale * self.width / (self.rate * 3)

    # データ設定処理
    def setData(self, vals, rate):
        self.vals = vals
        self.amp  = math.fabs(max(
            (np.floor(vals * 100000) / 100000).min(),
            (np.floor(vals * 100000) / 100000).max(), key=abs)) + 1
        self.rate = rate
        self.scale = self.orderScale * self.width / (self.rate * 3)

    # スクロール処理
    def __eventScroll(self, event):
        # スクロールバーの先頭と末尾位置を取得
        v = self.bar.get()
        self.dispSt = v[0]
        self.dispEd = v[1]
        # 画面を更新
        self.__updateDisplay()

    # [x]押下時処理
    def __eventFin(self):
        if self.rt is None:
            return
        self.rt.destroy()
        self.rt = None

    # 画面更新処理
    def __updateDisplay(self):
        if self.cnv is None:
            return

        # 描画済みのパーツを削除
        for item in self.items:
            self.cnv.delete(item)
        self.items = []

        # データ未設定時は何もしない
        if len(self.vals) == 0:
            return

        # スクロール位置に対応した描画先頭位置、末尾位置を取得
        dispStIdx = int(len(self.vals) * self.dispSt)
        dispEdIdx = int(len(self.vals) * self.dispEd)

        if dispStIdx >= dispEdIdx:
            return

        # 値の列を作成
        vals = self.vals[dispStIdx:dispEdIdx] # Y軸方向
        lbls = np.arange(len(vals)) + dispStIdx # X軸方向

        # 描画縮尺を加味したピクセル値の列を作成し、X,Y,X,Y,...の順に並べ替える
        conv_x = lbls * self.scale
        conv_y = vals / self.amp * ((self.height - 20)/2) * (-1) + ((self.height - 20)/2)
        data_s = list(np.array([conv_x, conv_y]).T.reshape(1, len(conv_x) + len(conv_y))[0])

        line = self.cnv.create_line(data_s, fill="Blue", width=1)
        self.items.append(line)

        # X軸を生成
        xaxe_x = [conv_x[0], conv_x[len(conv_x) - 1]]
        xaxe_y = [(self.height - 20)/2, (self.height - 20)/2]
        xaxe_s = list(np.array([xaxe_x, xaxe_y]).T.reshape(1, len(xaxe_x) + len(xaxe_y))[0])

        yoko = self.cnv.create_line(xaxe_s, fill="Black", width=1)
        self.items.append(yoko)

        # 目盛りを生成
        st = (dispStIdx // self.rate) * self.rate
        ed = (dispEdIdx // self.rate + 1) * self.rate
        for i in range(st, ed, self.rate // 10):
            if i < dispStIdx or i > dispEdIdx:
                continue
            l = 5

            xmem_x = list(np.array([i, i]) * self.scale)
            xmem_y = [(self.height - 20)/2 - l, (self.height - 20)/2 + l]
            xmem_s = list(np.array([xmem_x, xmem_y]).T.reshape(1, len(xmem_x) + len(xmem_y))[0])

            xmem = self.cnv.create_line(xmem_s, fill="Black", width=1)
            self.items.append(xmem)

    # 表示
    def display(self):

        data_width = len(self.vals) * self.scale
        if data_width < self.width:
            data_width = self.width

        rt = tk.Tk()
        rt.geometry(str(self.width) + "x" + str(self.height))

        cnv = tk.Canvas(rt, bg="white", width=self.width, height=self.height - 20)
        cnv.place(x=0, y=0)

        bar = tk.Scrollbar(rt, orient=tk.HORIZONTAL)
        bar.pack(side=tk.BOTTOM, fill=tk.X)

        bar.config(command=cnv.xview)
        cnv.config(scrollregion=(0, 0, data_width, self.height))
        cnv.config(xscrollcommand=bar.set)

        self.dispSt = 0
        self.dispEd = self.width / data_width

        bar.bind("<B1-Motion>", self.__eventScroll)
        bar.bind("<Button-1>", self.__eventScroll)
        rt.protocol("WM_DELETE_WINDOW", self.__eventFin)

        self.rt = rt
        self.cnv = cnv
        self.bar = bar

        self.__updateDisplay()

        while True:
            if self.rt is None:
                break

            self.rt.update_idletasks()
            self.rt.update()


class Bar:

    def __init__(self):
        # 画面サイズ
        self.width = 640
        self.height = 200

        self.wova = 50  # 縦軸の幅(Width of vertical axis)
        self.hoha = 70  # 横軸の高さ(Height of horizontal axis)

        self.woov = 30  # Width of One Value

        # データ、ラベル
        self.vals = np.array([]) # データ列
        self.lbls = []
        self.vavl = []
        self.maxv = 100
        self.minv = -100

        # graphical parts
        self.rt = None
        self.cnv_vw = None
        self.cnv_lb = None
        self.cnv_va = None
        self.vw_items = []
        self.lb_items = []
        self.va_items = []

    # Width of Viewarea
    def __wovw(self):
        return self.width - self.wova

    # Height of Viewarea
    def __hovw(self):
        return self.height - self.hoha

    # Height of one value
    def __hoov(self):
        return self.__hovw() / (self.maxv - self.minv)

    def setMaxViewarea(self, v):
        self.maxv = v
        self.vavl = self.__make_vaxis(self.maxv, self.minv, 6)

    def setMinViewarea(self, v):
        self.minv = v
        self.vavl = self.__make_vaxis(self.maxv, self.minv, 6)

    def setMaxMinViewarea(self, maxv, minv):
        self.setMaxViewarea(maxv)
        self.setMinViewarea(minv)

    def setMaxMinValueByDefault(self):
        maxv = np.amax(self.vals)
        minv = np.amin(self.vals)
        rngv = (maxv - minv) / 10 if (maxv - minv) > 100 else 10
        self.setMaxMinViewarea(maxv + rngv, minv - rngv)

    # set data
    def setData(self, vals):
        self.setDataLabel(vals, list(range(1, len(vals)+1)))
        return

    # set data and label
    def setDataLabel(self, vals, lbls):
        self.vals = vals
        if len(lbls) >= len(vals):
            self.lbls = lbls[:len(vals)]
        else:
            last_value = lbls[-1]
            self.lbls = lbls + list(range(last_value + 1, last_value + (len(vals) - len(lbls)) + 1))

        self.setMaxMinValueByDefault()
        self.setMinViewarea(-10)
        return

    def __eventFin(self):
        if self.rt is None:
            return
        self.rt.destroy()
        self.rt = None

    def __eventScroll(self, event):
        # スクロールバーの先頭と末尾位置を取得
        v = self.bar.get()
        self.dispSt = v[0]
        self.dispEd = v[1]

        # 画面を更新
        self.__updateDisplay()
        return

    def __eventWheel(self, event):
        #self.woov
        if event.delta > 0:
            # up
            self.woov += 2
        else:
            # down
            self.woov -= 2

        if self.woov < 5:
            self.woov = 6
        if self.woov > 100:
            self.woov = 100

        self.__updateDisplay()
        return

    def __coor_x(self, x):
        return x * self.woov + self.woov / 2

    def __coor_y(self, y):
        return self.__hovw() - (y - self.minv) * self.__hoov()

    # 10の何乗オーダーの値か
    # 91 -> 1,  1 -> 0,  0.15 -> -1,  0.05 -> -2
    def __flen(self, v):
        return math.floor(math.log10(v))

    # 桁指定切り上げ
    # 小数点以下 keta 桁数を保証して、その下の位で切り上げ
    def __roundup(self, v, keta):
        return math.ceil(v * math.pow(10, keta)) * math.pow(10, -1 * keta)

    # lim以下最大数で割り切った場合の商
    def __divmax(self, v, lim):
        r = lim
        while r >= 1:
            q = math.floor(v / r)
            if q * r == v:
                return q
            r -= 1
        return 1

    def __make_vaxis(self, maxv, minv, num):
        rng = maxv - minv
        ord = self.__flen(rng)
        ordnum = (1 - ord) if (ord < 1) else 0
        rng10 = rng * math.pow(10, ordnum)
        rngup = self.__roundup(rng10, -1 * self.__flen(rng10))
        r = self.__divmax(rngup, num)

        res = []
        st = int(math.floor((minv * math.pow(10, ordnum)) / r ) * r)
        ed = int(maxv * math.pow(10, ordnum))
        for idx in range(st, ed + 1, r):
            if idx < minv * math.pow(10, ordnum):
                continue
            res.append(idx)

        res = list(map(lambda e: e / math.pow(10, ordnum), res))
        return res

    def __updateVerticalAxis(self):
        if self.rt is None or self.cnv_va is None:
            return

        for item in self.va_items:
            self.cnv_va.delete(item)
            self.va_items.remove(item)

        if len(self.vavl) == 0:
            return

        for v in self.vavl:
            px = self.wova - 5
            py = self.__coor_y(v)

            item = self.cnv_va.create_text(px, py, text='{:1}'.format(v), anchor="e", fill="black")
            self.va_items.append(item)


    def __updateDisplay(self):
        if self.rt is None or self.cnv_vw is None or self.cnv_lb is None:
            return

        for item in self.vw_items:
            self.cnv_vw.delete(item)
        self.vw_items = []


        for item in self.lb_items:
            self.cnv_lb.delete(item)
        self.lb_items = []

        if len(self.vals) == 0:
            return

        data_width = len(self.vals) * self.woov
        if data_width < self.__wovw():
            data_width = self.__wovw()
        self.cnv_vw.config(width=self.__wovw())
        self.cnv_lb.config(width=self.__wovw())
        self.cnv_vw.config(scrollregion=(0, 0, data_width, self.height))
        self.cnv_lb.config(scrollregion=(0, 0, data_width, self.hoha))

        dispStIdx = int(len(self.vals) * self.dispSt)
        dispEdIdx = int(len(self.vals) * self.dispEd)
        if dispStIdx >= dispEdIdx:
            return

        y_val = self.vals[dispStIdx:dispEdIdx]
        l_val = self.lbls[dispStIdx:dispEdIdx]
        x_val = np.arange(len(y_val)) + dispStIdx

        for x, y, lb in zip(x_val, y_val, l_val):
            px = self.__coor_x(x)
            py_top = self.__coor_y(y)
            py_btm = self.__coor_y(0)

            item = self.cnv_lb.create_text(px, 5, text=str(lb), anchor="n", fill="black")
            self.lb_items.append(item)

            if py_top == py_btm:
                item = self.cnv_vw.create_line(
                    px - (self.woov / 2 - 1), py_top,
                    px + (self.woov / 2 - 1), py_btm, fill='black')
            else:
                color = 'blue'
                if py_top > py_btm:
                    color = 'red'
                item = self.cnv_vw.create_rectangle(
                    px - (self.woov / 2 - 1), py_top,
                    px + (self.woov / 2 - 1), py_btm, fill=color)
            self.vw_items.append(item)


        return

    def __sc(self, a, b, c=None):
        if self.cnv_vw is None or self.cnv_lb is None:
            return
        if c is None:
            self.cnv_vw.xview(a, b)
            self.cnv_lb.xview(a, b)
        else:
            self.cnv_vw.xview(a, b, c)
            self.cnv_lb.xview(a, b, c)
        return

    def display(self):

        data_width = len(self.vals) * self.woov
        if data_width < self.__wovw():
            data_width = self.__wovw()

        rt = tk.Tk()
        rt.geometry(str(self.width) + "x" + str(self.height))

        # view area
        cnv_vw = tk.Canvas(rt, bg="white", width=self.__wovw(), height=self.__hovw())
        cnv_vw.place(x=self.wova, y=0)

        # label area
        cnv_lb = tk.Canvas(rt, bg="white", width=self.__wovw(), height=self.hoha)
        cnv_lb.place(x=self.wova, y=self.__hovw())

        cnv_va = tk.Canvas(rt, bg="white", width=self.wova, height=self.__hovw())
        cnv_va.place(x=0, y=0)

        bar = tk.Scrollbar(rt, orient=tk.HORIZONTAL)
        bar.pack(side=tk.BOTTOM, fill=tk.X)

        bar.config(command=self.__sc)

        cnv_vw.config(scrollregion=(0, 0, data_width, self.height))
        cnv_lb.config(scrollregion=(0, 0, data_width, self.hoha))
        cnv_vw.config(xscrollcommand=bar.set)

        # set scrollbar value
        self.dispSt = 0
        self.dispEd = self.width / data_width

        bar.bind("<B1-Motion>", self.__eventScroll)
        bar.bind("<Button-1>", self.__eventScroll)
        cnv_vw.bind("<MouseWheel>", self.__eventWheel)
        cnv_lb.bind("<MouseWheel>", self.__eventWheel)
        rt.protocol("WM_DELETE_WINDOW", self.__eventFin)

        self.rt = rt
        self.cnv_vw = cnv_vw
        self.cnv_lb = cnv_lb
        self.cnv_va = cnv_va
        self.bar = bar

        self.__updateDisplay()
        self.__updateVerticalAxis()

        while True:
            if self.rt is None:
                break

            self.rt.update_idletasks()
            self.rt.update()
```

```p3.py
# -*- coding: utf-8 -*-
import sys
sys.dont_write_bytecode = True

import warnings
warnings.filterwarnings( 'ignore' )

import numpy as np
from pydub import AudioSegment

from Grp import Line

def main():
    dt_raw = AudioSegment.from_mp3("data.mp3")
    dt_arr = np.array(dt_raw.get_array_of_samples())
    dt_ch1 = dt_arr[::2]

    grp = Line()
    grp.setData(dt_ch1, dt_raw.frame_rate)

    grp.display()

if __name__ == '__main__':    

    main()
```

実行結果
![P03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2850619/4667f17c-0b1e-329d-5901-789ab2602713.png)

見える部分のみを描画することで、処理負荷軽減を図っています。
なお、tkinterのスクロールイベントで、少し手を抜いています（矢印ボタン押下でスクロールさせると、グラフが一瞬、途切れます）。