├── .gitattributes ├── res ├── pqg_pitchlines.gif ├── pqg_melspectrogram.gif └── pqg_spherical_kmeans.gif ├── README.md ├── run_pitchlines.py ├── run_melspectrogram.py ├── LICENSE ├── pqg_pitchlines.py ├── rasp_audio_stream.py ├── pqg_melspectrogram.py └── pqg_spherical_kmeans.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /res/pqg_pitchlines.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurene/pyqtgraph-app/HEAD/res/pqg_pitchlines.gif -------------------------------------------------------------------------------- /res/pqg_melspectrogram.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurene/pyqtgraph-app/HEAD/res/pqg_melspectrogram.gif -------------------------------------------------------------------------------- /res/pqg_spherical_kmeans.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurene/pyqtgraph-app/HEAD/res/pqg_spherical_kmeans.gif -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyqtgraph-app 2 | PyQtGraph audioapps created by [Kurene](https://twitter.com/_kurene) 3 | 4 | https://www.wizard-notes.com/ 5 | 6 | # app 7 | ## PyQtGraph MelsSectrogram 8 | ![Demo](res/pqg_melspectrogram.gif) 9 | 10 | ## PyQtGraph Pitch-lines 11 | ![Demo](res/pqg_pitchlines.gif) 12 | 13 | ## PyQtGraph Spherical K-means 14 | ![Demo](res/pqg_spherical_kmeans.gif) 15 | -------------------------------------------------------------------------------- /run_pitchlines.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from rasp_audio_stream import AudioInputStream 3 | from pqg_pitchlines import PQGPitchLines 4 | 5 | 6 | # PyAudioストリーム入力取得クラス 7 | ais = AudioInputStream(CHUNK=4096) #, input_device_keyword="Real") 8 | # メルスペクトログラム用クラス 9 | pitchlines = PQGPitchLines( ais.RATE, (ais.CHANNELS, ais.CHUNK) ) 10 | 11 | # AudioInputStreamは別スレッドで動かす 12 | import threading 13 | thread = threading.Thread(target=ais.run, args=(pitchlines.callback_sigproc,)) 14 | thread.daemon=True 15 | thread.start() 16 | 17 | # スペクトログラム描画開始 18 | pitchlines.run_app() -------------------------------------------------------------------------------- /run_melspectrogram.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from rasp_audio_stream import AudioInputStream 3 | from pqg_melspectrogram import PQGMelSpectrogram 4 | 5 | 6 | # PyAudioストリーム入力取得クラス 7 | ais = AudioInputStream(CHUNK=1024) #, input_device_keyword="Real") 8 | # メルスペクトログラム用クラス 9 | melspectrogram = PQGMelSpectrogram( ais.RATE, (ais.CHANNELS, ais.CHUNK) ) 10 | 11 | # AudioInputStreamは別スレッドで動かす 12 | import threading 13 | thread = threading.Thread(target=ais.run, args=(melspectrogram.callback_sigproc,)) 14 | thread.daemon=True 15 | thread.start() 16 | 17 | # スペクトログラム描画開始 18 | melspectrogram.run_app() -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Kurene 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pqg_pitchlines.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | import time 4 | import threading 5 | import numpy as np 6 | from numba import jit 7 | import librosa 8 | 9 | from pyqtgraph.Qt import QtGui, QtCore 10 | import numpy as np 11 | import pyqtgraph as pg 12 | 13 | 14 | # 散布図プロット用クラス 15 | class PQGPitchLines(): 16 | def __init__(self, 17 | sr, 18 | sig_shape, 19 | n_frames=150, 20 | fps=60, 21 | size=(500,500), 22 | title="" 23 | ): 24 | self.n_ch, self.n_chunk = sig_shape 25 | self.n_frames = n_frames 26 | self.n_chroma = 12 27 | self.n_freqs = self.n_chunk // 2 + 1 28 | self.sig = np.zeros(sig_shape) 29 | self.sig_mono = np.zeros(self.n_chunk) 30 | self.specs = np.zeros(self.n_freqs) 31 | self.chroma_pre = np.zeros(self.n_chroma) 32 | self.chroma = np.zeros(self.n_chroma) 33 | self.window = np.hamming(self.n_chunk) 34 | self.fft = np.fft.rfft 35 | self.chromafb = librosa.filters.chroma(sr, self.n_chunk, tuning=0.0, n_chroma=self.n_chroma) 36 | self.chromafb **= 2 37 | self.x = np.arange(0, self.n_frames) 38 | self.y = np.zeros((self.n_frames, self.n_chroma)) 39 | 40 | # PyQtGraph 散布図の初期設定 41 | self.app = QtGui.QApplication([]) 42 | self.win = pg.GraphicsLayoutWidget() 43 | self.win.resize(size[0], size[1]) 44 | self.win.show() 45 | 46 | self.plotitem = self.win.addPlot(title=title) 47 | self.plotitem.setXRange(0, self.n_frames) 48 | self.plotitem.setYRange(0, self.n_chroma) 49 | self.plotitem.showGrid(x=False, y=False) 50 | 51 | self.plots = [] 52 | for k in range(self.n_chroma): 53 | self.plots.append( 54 | self.plotitem.plot(pen=pg.mkPen((k, self.n_chroma), width=3)) 55 | ) 56 | 57 | self.fps = fps 58 | self.iter = 0 59 | 60 | pg.setConfigOptions(antialias=True) 61 | 62 | def update(self): 63 | idx = self.iter % self.n_frames 64 | 65 | self.sig_mono[:] = 0.5 * (self.sig[0] + self.sig[1]) 66 | pw = np.sqrt(np.mean(self.sig_mono**2)) 67 | self.sig_mono[:] = self.sig_mono[:] * self.window 68 | self.specs[:] = np.abs(self.fft(self.sig_mono))**2 69 | self.chroma[:] = np.dot(self.chromafb, self.specs) 70 | self.chroma[:] = self.chroma / (np.max(self.chroma)+1e-16) 71 | self.chroma[:] = 0.3*self.chroma+0.7*self.chroma_pre 72 | self.y[idx] = self.chroma[:] 73 | 74 | #print(self.y[idx]) 75 | pos = idx + 1 if idx < self.n_frames else 0 76 | for k in range(self.n_chroma): 77 | alpha = self.y[idx,k] * 0.9 78 | alpha = alpha if pw > 1e-3 else 0.0 79 | self.plots[k].setAlpha(alpha, False) 80 | self.y[idx,k] += k 81 | self.plots[k].setData( 82 | self.x, 83 | np.r_[self.y[pos:self.n_frames,k],self.y[0:pos,k]] 84 | ) 85 | 86 | self.chroma_pre[:] = self.chroma 87 | self.iter += 1 88 | 89 | 90 | def run_app(self): 91 | timer = QtCore.QTimer() 92 | timer.timeout.connect(self.update) 93 | timer.start(1/self.fps * 1000) 94 | 95 | if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): 96 | QtGui.QApplication.instance().exec_() 97 | 98 | 99 | def callback_sigproc(self, sig): 100 | self.sig[:] = sig 101 | -------------------------------------------------------------------------------- /rasp_audio_stream.py: -------------------------------------------------------------------------------- 1 | import pyaudio 2 | import numpy as np 3 | 4 | 5 | class AudioInputStream: 6 | def __init__(self, 7 | format=pyaudio.paFloat32, 8 | input_device_keyword="VoiceMeeter Output", 9 | CHUNK=1024, 10 | maxInputChannels=2 11 | ): 12 | self.maxInputChannels = maxInputChannels 13 | self.CHUNK = CHUNK 14 | self.format = format 15 | if format is pyaudio.paFloat32: 16 | self.dtype = np.float32 17 | elif format is pyaudio.paInt16: 18 | self.dtype = np.int16 19 | # Open the stream 20 | self.p = pyaudio.PyAudio() 21 | self.__open_stream(input_device_keyword) 22 | 23 | def get_params(self): 24 | params_dict = { 25 | "RATE": self.RATE, 26 | "CHUNK": self.CHUNK, 27 | "CHANNELS": self.CHANNELS, 28 | } 29 | return params_dict 30 | 31 | def __open_stream(self, input_device_keyword): 32 | self.input_device_index = None 33 | self.input_device_name = None 34 | self.devices = [] 35 | print(f"=========================================================") 36 | print(f"dev. index\tmaxInputCh.\tmaxOutputCh.\tdev. name") 37 | 38 | for k in range(self.p.get_device_count()): 39 | dev = self.p.get_device_info_by_index(k) 40 | self.devices.append(dev) 41 | device_name = dev["name"] 42 | device_index = dev["index"] 43 | maxInputChannels = int(dev["maxInputChannels"]) 44 | maxOutputChannels = int(dev["maxOutputChannels"]) 45 | 46 | if type(device_name) is bytes: 47 | device_name = device_name.decode("cp932") # for windows 48 | 49 | print(f"{device_index}\t{maxInputChannels}\t{maxOutputChannels}\t{device_name}") 50 | 51 | if input_device_keyword in device_name \ 52 | and maxInputChannels == self.maxInputChannels: 53 | self.input_device_index = dev["index"] 54 | self.input_device_name = device_name 55 | self.RATE = int(dev["defaultSampleRate"]) 56 | self.CHANNELS = dev["maxInputChannels"] 57 | 58 | if self.input_device_index is not None: 59 | print(f"=========================================================") 60 | print(f"Input device: {self.input_device_name} is OK.") 61 | print(f"\tRATE: {self.RATE}") 62 | print(f"\tCHANNELS: {self.CHANNELS}") 63 | print(f"\tCHUNK: {self.CHUNK}") 64 | print(f"=========================================================") 65 | else: 66 | print(f"\nWarning: Input device is not exist\n") 67 | 68 | self.stream = self.p.open( 69 | format=self.format, 70 | channels=self.CHANNELS, 71 | rate=self.RATE, 72 | input=True, 73 | output=False, 74 | frames_per_buffer=self.CHUNK, 75 | input_device_index=self.input_device_index, 76 | ) 77 | 78 | return self 79 | 80 | def run(self, callback_sigproc): 81 | while self.stream.is_active(): 82 | input_buff = self.stream.read(self.CHUNK) 83 | data = np.fromstring(input_buff, dtype=self.dtype) 84 | # data: []L, R, L, R, ..., L, R] => data[n_fft, 2] (data[n_fft, 0] is Left channel) 85 | sig = np.reshape(data, (self.CHUNK, self.CHANNELS)).T 86 | callback_sigproc(sig) 87 | self.__terminate() 88 | 89 | def __terminate(self): 90 | stream.stop_stream() 91 | stream.close() 92 | p.terminate() 93 | 94 | def test_callback_sigproc(sig): 95 | print(sig.shape) 96 | 97 | if __name__ == "__main__": 98 | ais = AudioInputStream() 99 | print(ais.get_params()) 100 | ais.run(test_callback_sigproc) 101 | 102 | -------------------------------------------------------------------------------- /pqg_melspectrogram.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | import time 4 | import threading 5 | import numpy as np 6 | from numba import jit 7 | import librosa 8 | """ 9 | import os 10 | import PySide6 11 | from PySide6 import QtGui, QtCore 12 | dirname = os.path.dirname(PySide6.__file__) 13 | plugin_path = os.path.join(dirname, 'plugins', 'platforms') 14 | os.environ['QT_QPA_PLATFORM_PLUGIN_PATH'] = plugin_path 15 | """ 16 | from pyqtgraph.Qt import QtGui, QtCore 17 | import numpy as np 18 | import pyqtgraph as pg 19 | 20 | from rasp_audio_stream import AudioInputStream 21 | 22 | 23 | class PQGMelSpectrogram(): 24 | def __init__(self, 25 | sr, 26 | shape, 27 | n_mels=128, 28 | n_frames=150, 29 | fps=60, 30 | size=(500,500), 31 | title="", 32 | ): 33 | # メルスペクトログラム算出用パラメタ 34 | self.n_frames = n_frames 35 | self.n_ch = shape[0] 36 | self.n_chunk = shape[1] 37 | self.n_freqs = self.n_chunk // 2 + 1 38 | self.n_mels = n_mels 39 | self.sig = np.zeros(shape) 40 | self.x = np.zeros(self.n_chunk) 41 | self.specs = np.zeros((self.n_freqs)) 42 | self.melspecs = np.zeros((self.n_frames, self.n_mels)) 43 | self.window = np.hamming(self.n_chunk) 44 | self.fft = np.fft.rfft 45 | self.melfreqs = librosa.mel_frequencies(n_mels=self.n_mels) 46 | self.melfb = librosa.filters.mel(sr, self.n_chunk, n_mels=self.n_mels) 47 | self.fps = fps 48 | self.iter = 0 49 | 50 | #==================================================== 51 | ## PyQtGraph の初期設定 52 | app = QtGui.QApplication([]) 53 | win = pg.GraphicsLayoutWidget() 54 | win.resize(size[0], size[1]) 55 | win.show() 56 | 57 | ## ImageItem の設定 58 | imageitem = pg.ImageItem(border="k") 59 | cmap = pg.colormap.getFromMatplotlib("jet") 60 | bar = pg.ColorBarItem( cmap=cmap ) 61 | bar.setImageItem(imageitem) 62 | 63 | ## ViewBox の設定 64 | viewbox = win.addViewBox() 65 | viewbox.setAspectLocked(lock=True) 66 | viewbox.addItem(imageitem) 67 | 68 | ## 軸 (AxisItem) の設定 69 | axis_left = pg.AxisItem(orientation="left") 70 | n_ygrid = 6 71 | yticks = {} 72 | for k in range(n_ygrid): 73 | index = k*(self.n_mels//n_ygrid) 74 | yticks[index] = int(self.melfreqs[index]) 75 | axis_left.setTicks([yticks.items()]) 76 | 77 | ## PlotItemの設定 78 | plotitem = pg.PlotItem(viewBox=viewbox, axisItems={"left":axis_left}) 79 | # グラフの範囲 80 | plotitem.setLimits( 81 | minXRange=0, maxXRange=self.n_frames, 82 | minYRange=0, maxYRange=self.n_mels) 83 | # アスペクト比固定 84 | plotitem.setAspectLocked(lock=True) 85 | # マウス操作無効 86 | plotitem.setMouseEnabled(x=False, y=False) 87 | # ラベルのセット 88 | plotitem.setLabels(bottom="Time-frame", 89 | left="Frequency") 90 | win.addItem(plotitem) 91 | 92 | self.app = app 93 | self.win = win 94 | self.viewbox = viewbox 95 | self.plotitem = plotitem 96 | self.imageitem = imageitem 97 | 98 | pg.setConfigOptions(antialias=True) 99 | #pg.setConfigOption('useNumba', True) 100 | 101 | def run_app(self): 102 | timer = QtCore.QTimer() 103 | timer.timeout.connect(self.update) 104 | timer.start(1/self.fps * 1000) 105 | 106 | if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): 107 | QtGui.QApplication.instance().exec_() 108 | 109 | def update(self): 110 | if self.iter > 0: 111 | self.viewbox.disableAutoRange() 112 | 113 | # 最新をスペクトログラム格納するインデックス 114 | idx = self.iter % self.n_frames 115 | # モノラル信号算出 116 | self.x[:] = 0.5 * (self.sig[0] + self.sig[1]) 117 | # FFT => パワー算出 118 | self.x[:] = self.x[:] * self.window 119 | self.specs[:] = np.abs(self.fft(self.x))**2 120 | # メルスペクトログラム算出 121 | self.melspecs[idx, :] = np.dot(self.melfb, self.specs) 122 | 123 | # 描画 124 | pos = idx + 1 if idx < self.n_frames else 0 125 | self.imageitem.setImage( 126 | librosa.power_to_db( 127 | np.r_[self.melspecs[pos:self.n_frames], 128 | self.melspecs[0:pos]] 129 | , ref=np.max) 130 | ) 131 | self.iter += 1 132 | 133 | 134 | def callback_sigproc(self, sig): 135 | self.sig[:] = sig -------------------------------------------------------------------------------- /pqg_spherical_kmeans.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | import time 4 | import numpy as np 5 | 6 | from pyqtgraph.Qt import QtGui, QtCore 7 | import pyqtgraph as pg 8 | 9 | 10 | # 散布図プロット用クラス 11 | class KMeans2D(): 12 | def __init__(self, 13 | n_samples, 14 | n_labels, 15 | spherical_mode=True, 16 | max_iter=100, 17 | fps=1, 18 | xrange=[-1.5, 1.5], 19 | yrange=[-1.5, 1.5], 20 | size=(500,500), 21 | title="", 22 | ): 23 | self.n_samples = n_samples 24 | self.n_labels = n_labels 25 | self.fps = fps 26 | 27 | # PyQtGraph 28 | self.app = QtGui.QApplication([]) 29 | self.win = pg.GraphicsLayoutWidget() 30 | self.win.resize(size[0], size[1]) 31 | self.win.show() 32 | 33 | self.plotitem = self.win.addPlot(title=title) 34 | self.plotitem.setXRange(xrange[0], xrange[1]) 35 | self.plotitem.setYRange(yrange[0], yrange[1]) 36 | self.plotitem.showGrid(x = True, y = True, alpha = 0.3) 37 | 38 | self.plot_data = [] 39 | for k in range(self.n_labels): 40 | self.plot_data.append( 41 | self.plotitem.plot( 42 | pen=None, 43 | symbol="o", 44 | symbolPen='b', 45 | symbolSize=10, 46 | symbolBrush=pg.mkBrush(pg.intColor(k, hues=self.n_labels)) 47 | ) 48 | ) 49 | self.plot_centroids = self.plotitem.plot( 50 | pen=None, 51 | symbol="x", 52 | symbolPen='b', 53 | symbolSize=10, 54 | symbolBrush="c" 55 | ) 56 | pg.setConfigOptions(antialias=True) 57 | 58 | if spherical_mode: 59 | self.calc_init_centroids = calc_init_centroids_cos 60 | self.distance = distance_cos 61 | self.calc_centroid = calc_centroid_cos 62 | else: 63 | self.calc_init_centroids = calc_init_centroids_euc 64 | self.distance = distance_euc 65 | self.calc_centroid = calc_centroid_euc 66 | 67 | 68 | def run(self, xy): 69 | self.iter = 0 70 | self.xy = xy.copy() 71 | self.labels = np.zeros(self.n_samples) 72 | self.centroids = np.zeros((self.n_labels, 2)) 73 | self.calc_init_centroids(self.centroids, self.n_labels) 74 | 75 | timer = QtCore.QTimer() 76 | timer.timeout.connect(self.update) 77 | timer.start(int(1/self.fps * 1000)) 78 | 79 | if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): 80 | QtGui.QApplication.instance().exec_() 81 | 82 | def update(self): 83 | if self.iter % 2 == 0: 84 | for k in range(self.n_samples): 85 | dist = 1e15 86 | for m in range(self.n_labels): 87 | tmp_dist = self.distance(self.xy[k], self.centroids[m]) 88 | if tmp_dist < dist: 89 | dist = tmp_dist 90 | self.labels[k] = m 91 | else: 92 | for m in range(self.n_labels): 93 | tmp_xy = self.xy[self.labels==m] 94 | length = tmp_xy.shape[0] 95 | self.calc_centroid(tmp_xy, self.centroids, m) 96 | 97 | # Draw 98 | for m in range(self.n_labels): 99 | tmp_xy = self.xy[self.labels==m] 100 | if tmp_xy.shape[0] > 0: 101 | self.plot_data[m].setData(tmp_xy[:,0], tmp_xy[:,1]) 102 | self.plot_data[m].setAlpha(0.7, False) 103 | self.plot_centroids.setData( 104 | self.centroids[:,0], self.centroids[:,1]) 105 | 106 | self.iter += 1 107 | 108 | def calc_init_centroids_cos(centroids, n_labels): 109 | for k in range(n_labels): 110 | theta = np.random.random(1)*2*np.pi 111 | centroids[k,0] = np.cos(theta) 112 | centroids[k,1] = np.sin(theta) 113 | 114 | def calc_init_centroids_euc(centroids, n_labels): 115 | for k in range(n_labels): 116 | centroids[k] = np.random.random(2)*2-1 117 | 118 | def distance_cos(x, v): 119 | return 1.0 - np.dot(x, v) 120 | 121 | def distance_euc(x, v): 122 | return np.sum((x-v)**2) 123 | 124 | def calc_centroid_cos(x, v, m): 125 | v[m] = np.sum(x, axis=0) 126 | v[m] /= np.linalg.norm(v[m]) + 1e-15 127 | 128 | def calc_centroid_euc(x, v, m): 129 | v[m] = np.mean(x, axis=0) 130 | 131 | 132 | if __name__ == "__main__": 133 | n_samples = int(sys.argv[1]) 134 | n_labels = int(sys.argv[2]) 135 | mode = int(sys.argv[3]) 136 | 137 | x = np.random.normal(0.0, 0.5, (n_samples,2)) 138 | spherical_mode = True if mode == 0 else False 139 | kmeans2d = KMeans2D(x.shape[0], n_labels, spherical_mode=spherical_mode) 140 | kmeans2d.run(x) --------------------------------------------------------------------------------