Pythonでマイクの音をリアルタイムグラフ化!TkinterとMatplotlibで簡単ビジュアライザー作成

Pythonを使って、マイクから拾った音をリアルタイムでグラフに表示してみたくないですか?この記事では、そんな夢を叶えるお手伝いをします!TkinterとMatplotlibという強力なライブラリを使って、誰でも簡単に音声ビジュアライザーを作れるんです。プログラミング初心者の方でも大丈夫!ステップバイステップで丁寧に解説していきます。

音を「見る」ってどういうこと?

音は空気の振動によって伝わりますが、目に見えないため、その振動の様子を把握するのは難しいです。リアルタイム音声ビジュアライザーは、音を波形としてグラフに表示することで、音の強弱や変化を視覚的に捉えることができるツールです。

例えば、音声認識アプリを作っている時、マイクの音声が正しく入力されているかを確認したい時ってありますよね?そんな時、リアルタイムで波形が見えるととっても便利なんです!他にも、楽器のチューニングをしたり、音声を分析したり、音の性質を学ぶ教材としても活用できます。

必要なライブラリを準備しよう!

まずは、必要なライブラリをインストールしましょう。コマンドプロンプトやターミナルを開いて、以下のコマンドを実行してくださいね。

pip install matplotlib pyaudio numpy
  • Matplotlib: グラフを描くのが得意なライブラリです。音の波形をきれいに表示してくれます。
  • PyAudio: マイクの音声入力を受け取ってくれるライブラリです。
  • NumPy: 数値計算が得意なライブラリです。音のデータを扱うのに必要です。
  • Tkinter: 画面にウィンドウやボタンを表示するためのライブラリです。Pythonに標準で入っているので、インストールは不要ですよ!

プログラム

mic_hmi.py

mic_hmi.py は、Tkinterを使ってアプリの画面を作ります。マイクを選ぶためのリスト、接続ボタン、そして音の波形が表示されるグラフエリアがあります。スクロールできるテキストエリアには、マイクの情報が表示されます。

#! /usr/bin/env python3
#  -*- coding: utf-8 -*-
#
# GUI module generated by PAGE version 8.0
#  in conjunction with Tcl version 8.6
#    Oct 12, 2024 11:01:57 PM JST  platform: Windows NT

import sys
import tkinter as tk
import tkinter.ttk as ttk
from tkinter.constants import *
import os.path

_location = os.path.dirname(__file__)

import mic_hmi_support

_bgcolor = '#d9d9d9'
_fgcolor = '#000000'
_tabfg1 = 'black' 
_tabfg2 = 'white' 
_bgmode = 'light' 
_tabbg1 = '#d9d9d9' 
_tabbg2 = 'gray40' 

_style_code_ran = 0
def _style_code():
    global _style_code_ran
    if _style_code_ran: return        
    try: mic_hmi_support.root.tk.call('source',
                os.path.join(_location, 'themes', 'default.tcl'))
    except: pass
    style = ttk.Style()
    style.theme_use('default')
    style.configure('.', font = "TkDefaultFont")
    if sys.platform == "win32":
       style.theme_use('winnative')    
    _style_code_ran = 1

class Toplevel1:
    def __init__(self, top=None):
        '''This class configures and populates the toplevel window.
           top is the toplevel containing window.'''

        top.geometry("843x450+516+208")
        top.minsize(120, 1)
        top.maxsize(3844, 1061)
        top.resizable(1,  1)
        top.title("Toplevel 0")
        top.configure(background="#d9d9d9")
        top.configure(highlightbackground="#d9d9d9")
        top.configure(highlightcolor="#000000")

        self.top = top
        self.combobox = tk.StringVar()

        self.Canvas1 = tk.Canvas(self.top)
        self.Canvas1.place(relx=0.356, rely=0.044, relheight=0.918
                , relwidth=0.625)
        self.Canvas1.configure(background="#d9d9d9")
        self.Canvas1.configure(borderwidth="2")
        self.Canvas1.configure(cursor="fleur")
        self.Canvas1.configure(highlightbackground="#d9d9d9")
        self.Canvas1.configure(highlightcolor="#000000")
        self.Canvas1.configure(insertbackground="#000000")
        self.Canvas1.configure(relief="ridge")
        self.Canvas1.configure(selectbackground="#d9d9d9")
        self.Canvas1.configure(selectforeground="black")

        _style_code()
        self.TCombobox1 = ttk.Combobox(self.top)
        self.TCombobox1.place(relx=0.013, rely=0.044, relheight=0.064
                , relwidth=0.235)
        self.TCombobox1.configure(font="-family {Yu Gothic UI} -size 9")
        self.TCombobox1.configure(textvariable=self.combobox)

        self.TButton1 = ttk.Button(self.top)
        self.TButton1.place(relx=0.262, rely=0.053, height=26, width=65)
        self.TButton1.configure(command=mic_hmi_support.connect_button_on_click)
        self.TButton1.configure(text='''Connect''')
        self.TButton1.configure(compound='left')

        self.Scrolledtext1 = ScrolledText(self.top)
        self.Scrolledtext1.place(relx=0.012, rely=0.133, relheight=0.829
                , relwidth=0.336)
        self.Scrolledtext1.configure(background="white")
        self.Scrolledtext1.configure(font="TkTextFont")
        self.Scrolledtext1.configure(foreground="black")
        self.Scrolledtext1.configure(highlightbackground="#d9d9d9")
        self.Scrolledtext1.configure(highlightcolor="#000000")
        self.Scrolledtext1.configure(insertbackground="#000000")
        self.Scrolledtext1.configure(insertborderwidth="3")
        self.Scrolledtext1.configure(selectbackground="#d9d9d9")
        self.Scrolledtext1.configure(selectforeground="black")
        self.Scrolledtext1.configure(wrap="none")

# The following code is added to facilitate the Scrolled widgets you specified.
class AutoScroll(object):
    '''Configure the scrollbars for a widget.'''
    def __init__(self, master):
        #  Rozen. Added the try-except clauses so that this class
        #  could be used for scrolled entry widget for which vertical
        #  scrolling is not supported. 5/7/14.
        try:
            vsb = ttk.Scrollbar(master, orient='vertical', command=self.yview)
        except:
            pass
        hsb = ttk.Scrollbar(master, orient='horizontal', command=self.xview)
        try:
            self.configure(yscrollcommand=self._autoscroll(vsb))
        except:
            pass
        self.configure(xscrollcommand=self._autoscroll(hsb))
        self.grid(column=0, row=0, sticky='nsew')
        try:
            vsb.grid(column=1, row=0, sticky='ns')
        except:
            pass
        hsb.grid(column=0, row=1, sticky='ew')
        master.grid_columnconfigure(0, weight=1)
        master.grid_rowconfigure(0, weight=1)
        # Copy geometry methods of master  (taken from ScrolledText.py)
        methods = tk.Pack.__dict__.keys() | tk.Grid.__dict__.keys() \
                  | tk.Place.__dict__.keys()
        for meth in methods:
            if meth[0] != '_' and meth not in ('config', 'configure'):
                setattr(self, meth, getattr(master, meth))

    @staticmethod
    def _autoscroll(sbar):
        '''Hide and show scrollbar as needed.'''
        def wrapped(first, last):
            first, last = float(first), float(last)
            if first <= 0 and last >= 1:
                sbar.grid_remove()
            else:
                sbar.grid()
            sbar.set(first, last)
        return wrapped

    def __str__(self):
        return str(self.master)

def _create_container(func):
    '''Creates a ttk Frame with a given master, and use this new frame to
    place the scrollbars and the widget.'''
    def wrapped(cls, master, **kw):
        container = ttk.Frame(master)
        container.bind('<Enter>', lambda e: _bound_to_mousewheel(e, container))
        container.bind('<Leave>', lambda e: _unbound_to_mousewheel(e, container))
        return func(cls, container, **kw)
    return wrapped

class ScrolledText(AutoScroll, tk.Text):
    '''A standard Tkinter Text widget with scrollbars that will
    automatically show/hide as needed.'''
    @_create_container
    def __init__(self, master, **kw):
        tk.Text.__init__(self, master, **kw)
        AutoScroll.__init__(self, master)

import platform
def _bound_to_mousewheel(event, widget):
    child = widget.winfo_children()[0]
    if platform.system() == 'Windows' or platform.system() == 'Darwin':
        child.bind_all('<MouseWheel>', lambda e: _on_mousewheel(e, child))
        child.bind_all('<Shift-MouseWheel>', lambda e: _on_shiftmouse(e, child))
    else:
        child.bind_all('<Button-4>', lambda e: _on_mousewheel(e, child))
        child.bind_all('<Button-5>', lambda e: _on_mousewheel(e, child))
        child.bind_all('<Shift-Button-4>', lambda e: _on_shiftmouse(e, child))
        child.bind_all('<Shift-Button-5>', lambda e: _on_shiftmouse(e, child))

def _unbound_to_mousewheel(event, widget):
    if platform.system() == 'Windows' or platform.system() == 'Darwin':
        widget.unbind_all('<MouseWheel>')
        widget.unbind_all('<Shift-MouseWheel>')
    else:
        widget.unbind_all('<Button-4>')
        widget.unbind_all('<Button-5>')
        widget.unbind_all('<Shift-Button-4>')
        widget.unbind_all('<Shift-Button-5>')

def _on_mousewheel(event, widget):
    if platform.system() == 'Windows':
        widget.yview_scroll(-1*int(event.delta/120),'units')
    elif platform.system() == 'Darwin':
        widget.yview_scroll(-1*int(event.delta),'units')
    else:
        if event.num == 4:
            widget.yview_scroll(-1, 'units')
        elif event.num == 5:
            widget.yview_scroll(1, 'units')

def _on_shiftmouse(event, widget):
    if platform.system() == 'Windows':
        widget.xview_scroll(-1*int(event.delta/120), 'units')
    elif platform.system() == 'Darwin':
        widget.xview_scroll(-1*int(event.delta), 'units')
    else:
        if event.num == 4:
            widget.xview_scroll(-1, 'units')
        elif event.num == 5:
            widget.xview_scroll(1, 'units')
def start_up():
    mic_hmi_support.main()

if __name__ == '__main__':
    mic_hmi_support.main()

mic_hmi_support.py

mic_hmi_support.py は、マイクから受け取った音をグラフに表示する役割を担っています。

  • list_microphones 関数: 使えるマイクをリストアップして、画面に表示してくれます。
  • device_on_selected 関数: 選んだマイクの情報を画面に表示してくれます。
  • connect_button_on_click 関数: 接続ボタンを押すと、マイクからの音声入力が開始・停止されます。audiostart関数で音声入力を受け取り始め、audiostop関数で止めます。
  • read_plot_data 関数: 裏側でこっそり、マイクから音を受け取ってグラフに表示する作業を繰り返しています。これのおかげで、グラフがリアルタイムで更新されるんです!Matplotlibで波形を描いて、NumPyで音のデータを処理しています。
#! /usr/bin/env python3
#  -*- coding: utf-8 -*-
#
# Support module generated by PAGE version 8.0
#  in conjunction with Tcl version 8.6
#    Oct 12, 2024 10:06:52 PM JST  platform: Windows NT

import sys
import tkinter as tk
import tkinter.ttk as ttk
from tkinter.constants import *

import mic_hmi

import pyaudio
import threading
import matplotlib.pyplot as plot
import numpy
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg

devices = []
audio = None
stream = None
fig = None
ax = None
canvas = None

def main(*args):
    '''Main entry point for the application.'''
    global root
    root = tk.Tk()
    root.protocol( 'WM_DELETE_WINDOW' , root.destroy)
    # Creates a toplevel widget.
    global _top1, _w1
    _top1 = root
    _w1 = mic_hmi.Toplevel1(_top1)

    list_microphones(_w1.TCombobox1)
    _w1.TCombobox1.bind("<<ComboboxSelected>>", device_on_selected)

    global fig, ax, canvas
    fig, ax = plot.subplots()
    canvas = FigureCanvasTkAgg(fig, master=_w1.Canvas1)
    canvas.get_tk_widget().pack(fill=BOTH, expand=True)  # pack を追加
    
    ax.set_xticks([])
    ax.set_yticks([])
    ax.set_xticklabels([])
    ax.set_yticklabels([])
    ax.axhline(y=0, color='black', linewidth=0.5)
    
    canvas.draw()

    root.mainloop()


def list_microphones( combo ):
    global devices
    
    p = pyaudio.PyAudio()
    info = p.get_host_api_info_by_index(0)
    numdevices = info.get('deviceCount')
    updated_values=[]
    for i in range(0, numdevices):
        if (p.get_device_info_by_host_api_device_index(0, i).get('maxInputChannels')) > 0:
             # デバイスをリストに追加
            devices.append({
                "index": i,
                "name": p.get_device_info_by_host_api_device_index(0, i).get('name'),
                "maxInputChannels": p.get_device_info_by_host_api_device_index(0, i).get('maxInputChannels'),
                "maxOutputChannels": p.get_device_info_by_host_api_device_index(0, i).get('maxOutputChannels'),
                "defaultSampleRate": p.get_device_info_by_host_api_device_index(0, i).get('defaultSampleRate')
            })
            new_value = f"Input Device  {i}"
            updated_values.append(new_value)
            
            
    p.terminate()
    combo["values"] =updated_values
    
    
def device_on_selected(event):
    global _w1
    # イベント発生元のウィジェットを取得
    widget = event.widget
    selected_value = widget.get()  # イベント発生元のComboboxの選択された値を取得
    #print(f"Selected value from {widget}: {selected_value}")
    
    device_number = int(selected_value.split("Device ")[1])
    
    device = devices[device_number]
    device_info = (
            f"Index: {device['index']}, \n"
            f"name: {device['name']}, \n"
            f"maxInputChannels: {device['maxInputChannels']}, \n"
            f"maxOutputChannels: {device['maxOutputChannels']}, \n"
            f"defaultSampleRate: {device['defaultSampleRate']}, \n"
        )
    _w1.Scrolledtext1.delete(1.0, 'end')  # 全削除(0から末尾まで)
    _w1.Scrolledtext1.insert('end', device_info)  # 新しいテキストを挿入
    
def connect_button_on_click(*args):
    global _w1
    if _w1.TButton1['text']=="Connect":
        _w1.TButton1['text']="Disconnect"
        selected_index = _w1.TCombobox1.current()  # 選択されたインデックスを取得
        if 0 <= selected_index < len(devices):  # インデックスが有効範囲内か確認
            device_index = devices[selected_index]["index"]
            audiostart(device_index) # 選択されたデバイスのインデックスを渡す
            plotting_thread = threading.Thread(target=read_plot_data) # スレッドに名前を付ける
            plotting_thread.daemon = True # デーモンスレッド化
            plotting_thread.start()
    else:
        _w1.TButton1['text']="Connect"
        audiostop()
    

def audiostart( device_index ):
    global audio, stream
    try:
        audio = pyaudio.PyAudio()
        stream = audio.open(format=pyaudio.paInt16,
                            rate=44100,
                            channels=1,
                            input_device_index=device_index, # ここで使用する
                            input=True,
                            frames_per_buffer=10240)
    except OSError as e:
        print(f"Error opening stream: {e}")
        stream = None # エラー発生時はstreamをNoneにする
        # 必要に応じてエラーメッセージをGUIに表示する処理を追加
                
def audiostop():
    global audio, stream, plotting_thread # plotting_thread を追加
    if stream:
        stream.stop_stream()
        stream.close()
    if audio:
        audio.terminate()
    stream = None # stream を None に設定

def read_plot_data():
    global stream, canvas, fig, ax
    if stream is None or canvas is None or fig is None or ax is None:
        return

    while stream: # stream が存在する間ループ
        try:
            data = stream.read(10240)
            audiodata = numpy.frombuffer(data, dtype='int16')
                    
            ax.cla()  # 毎回クリア
            # 軸の目盛りとラベルを消す
            ax.set_xticks([])
            ax.set_yticks([])
            ax.set_xticklabels([])
            ax.set_yticklabels([])
            ax.axhline(y=0, color='black', linewidth=0.5)
            
            ax.plot(audiodata)
            canvas.draw()
        except OSError as e:
            print(f"Error reading from stream: {e}")
            audiostop() # エラー発生時はstreamを停止
            break # ループを抜ける
    
if __name__ == '__main__':
    mic_hmi.start_up()

アプリを実行してみよう!

  1. mic_hmi.pyとmic_hmi_support.pyを同じフォルダに保存します。
  2. コマンドプロンプトまたはターミナルで以下のコマンドを実行します。
python mic_hmi.py

注意点

  • このプログラムは、×ボタンを押してもアプリが正しく終了しない場合があります。
  • マイクのアクセス許可を確認してください。

まとめ

PythonとTkinter、Matplotlib、PyAudioを用いることで、誰でも簡単にリアルタイム音声ビジュアライザーを作成できます。この記事を参考にして、自分だけのビジュアライザーを作成し、音の世界をさらに深く探求してみましょう!

この記事が、あなたの役に立てば幸いです!

Pythonの学習には、こちらを参考にしてみてください

にいやん

出身 : 関西 居住区 : 関西 職業 : 組み込み機器エンジニア (エンジニア歴13年) 年齢 : 38歳(2022年11月現在) 最近 業務の効率化で噂もありPython言語に興味を持ち勉強しています。 そこで学んだことを記事にして皆さんとシェアさせていただければと思いブログをはじめました!! 興味ある記事があれば皆さん見ていってください!! にほんブログ村