├── LICENSE
├── README.md
├── harmony_visualizer.py
├── icon.ico
├── icon.png
├── icon_128x128.png
├── images
└── harmony_visualizer.png
└── requirements.txt
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2022, Frieve-A
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | 2. Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | 3. Neither the name of the copyright holder nor the names of its
17 | contributors may be used to endorse or promote products derived from
18 | this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Harmony Visualizer
2 | A tool for visualizing the harmony of MIDI input.
3 |
4 | (英語に続き日本語の解説があります)
5 |
6 | 
7 |
8 | A tool for visualizing the harmony of the sound using virtual overtones based on MIDI input.
9 |
10 |
11 |
12 |
13 | ## How to download and install on Windows
14 |
15 | Latest version for 64bit Windows can be downloaded from the following page.
16 |
17 | https://github.com/Frieve-A/harmony_visualizer/releases
18 |
19 | Unzip the downloaded zip file and run the harmony_visualizer.exe to launch the app. No installation is required.
20 |
21 |
22 |
23 | ## How to execute on other platforms
24 |
25 | This application is written in Python.
26 | Follow the steps below to execute the Python code of the Harmony Visualizer.
27 |
28 | 1. git clone https://github.com/Frieve-A/harmony_visualizer.git
29 | 2. pip install -r requirements.txt
30 | 3. python harmony_visualizer.py
31 |
32 |
33 |
34 | ## How to use
35 |
36 | If a MIDI keyboard is available, connect the MIDI keyboard to your PC and launch the app.
37 |
38 | When you play a note with the MIDI keyboard or click on the keyboard, the notes you play are displayed in different colors on the keyboard for each note name. For example, Do is displayed in red, and So is displayed in blue which is a little closer to green.
39 |
40 | Above the keyboard display, a vertical line is displayed to indicate the volumme of the virtual overtones. For example, if you play Do, a vertical line indicating the second overtone is displayed at the Do position one octave higher, and the third overtone is displayed at the So position.
41 |
42 | If you input two or more sounds at the same time and the overtones are in harmony with each other, the overtones that are in harmony with the input sound are connected by a curve. The numbers on the curve indicate which overtones are in harmony.
43 |
44 | The graph on the upper left shows the volume of each overtone as a polar coordinate graph with a radius as a pitch and an angle that makes one round in one octave.
45 |
46 | To hold the display, press the CTRL key on your computer keyboard or press the damper pedal on your MIDI keyboard.
47 |
48 | Play your favorite songs and visually enjoy the harmony of the overtones!
49 |
50 |
51 |
52 | ## Keyboard shortcuts
53 |
54 | ESC : Exit the app
55 |
56 | F11 : Switch to full screen
57 |
58 |
59 |
60 | ---
61 |
62 |
63 |
64 | MIDI入力を元にした仮想的な倍音を用いながら、音のハーモニーを可視化するツールです。
65 |
66 |
67 |
68 |
69 | ## ダウンロードとインストール(Windows)
70 |
71 | 以下のページより64bit Windows用の最新バージョンをダウンロードします。
72 |
73 | https://github.com/Frieve-A/harmony_visualizer/releases
74 |
75 | ダウンロードしたzipファイルを解凍し、harmony_visualizer.exeファイル起動します。インストールは不要です。
76 |
77 |
78 |
79 | ## その他のプラットフォームで実行するには
80 |
81 | 本アプリケーションはPythonで作成されています。
82 | 以下の手順でHarmony VisualizerのPythonコードを実行します。
83 |
84 | 1. git clone https://github.com/Frieve-A/harmony_visualizer.git
85 | 2. pip install -r requirements.txt
86 | 3. python harmony_visualizer.py
87 |
88 |
89 |
90 | ## 使い方
91 |
92 | MIDIキーボードが利用できる場合、PCにMIDIキーボードを接続してアプリを起動します。
93 |
94 | MIDIキーボードで音を弾くか、鍵盤上をクリックすると、弾いた音は音名毎にそれぞれ異なる色で鍵盤上に色分け表示されます。例えばドは赤色で、ソは少しグリーン寄りの青で表示されます。
95 |
96 | 鍵盤表示の上には、仮想的な倍音の大きさを示す縦線が表示されます。例えばドを弾いた場合、1オクターブ上のドの位置に2倍音、ソの位置に3倍音を示す縦線が表示されます。
97 |
98 | 2つ以上のを同時に入力しその倍音同士が調和している場合、入力した音と調和している倍音は曲線で結ばれます。曲線の上の数字は調和しているのが何番目の倍音であるかを示しています。
99 |
100 | 左上のグラフはそれぞれの倍音の強度を、半径を音程とし、角度を1オクターブで1周するように設定した極座標グラフで表示したものです。
101 |
102 | 表示をホールドするには、パソコンのキーボードのCTRLキーを押すか、MIDIキーボードのダンパーペダルを踏みます。
103 |
104 | 好きな楽曲を演奏し、倍音同士が調和する様子を視覚的に楽しみましょう!
105 |
106 | ## キーボードショートカット
107 |
108 | ESC : アプリを終了
109 |
110 | F11 : フルスクリーンへの切り替え
111 |
112 |
113 |
114 |
115 |
--------------------------------------------------------------------------------
/harmony_visualizer.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import math
4 | import time
5 | import pygame
6 | import pygame.midi
7 | from pygame.locals import *
8 | import numpy as np
9 |
10 | app_title = 'Harmony visualizer'
11 | screen_size = (1920, 1080)
12 | #screen_size = (2560, 1440)
13 | #screen_size = (3840, 2160)
14 | line_width = screen_size[1] // 1081 + 1
15 | bold_line_width = line_width * 3
16 | base_size = screen_size[0] // 240 # 8 pixel in 1920 x 1080
17 | keyboard_margin_x = base_size * 3
18 | key_width = (screen_size[0] - keyboard_margin_x * 2) / 52
19 | keyboard_top = base_size * 100
20 | energy_bottom = keyboard_top - keyboard_margin_x
21 | white_key_width = round(key_width * 33 / 36) #22.5 / 23.5
22 | white_key_height = round(key_width * 150 / 23.5)
23 | black_key_width = round(key_width * 23 / 36) #15 / 23.5
24 | black_key_height = round(key_width * 100 / 23.5)
25 | min_note_no = 21
26 | max_note_no = 109.2 # max spectrum frequency in note no
27 | energy_width = (key_width * 7) / 12 - 2
28 | polar_overtone_radius = screen_size[0] // 10
29 | polar_overtone_center = (polar_overtone_radius + base_size * 7, polar_overtone_radius + base_size * 19)
30 | tone_color = np.array([
31 | (255, 128, 128),
32 | (224, 160, 128),
33 | (192, 192, 128),
34 | (160, 224, 128),
35 | (128, 255, 128),
36 | (128, 224, 160),
37 | (128, 192, 192),
38 | (128, 160, 224),
39 | (128, 128, 255),
40 | (160, 128, 224),
41 | (192, 128, 192),
42 | (224, 128, 160)
43 | ], dtype=np.int32)
44 |
45 | def prepare_midi_ins():
46 | pygame.midi.init()
47 | midi_ins = []
48 | midi_in_info = ''
49 | for i in range(pygame.midi.get_count()):
50 | midi_device_info = pygame.midi.get_device_info(i)
51 | if midi_device_info[2] and not midi_device_info[4]: # is input device and not opened.
52 | try:
53 | midi_ins.append(pygame.midi.Input(i))
54 | except:
55 | # Failed to open MIDI in
56 | continue
57 | if midi_in_info:
58 | midi_in_info += ' and '
59 | midi_in_info += '"' + midi_device_info[1].decode('utf-8') + '"'
60 | if midi_in_info:
61 | midi_in_info = 'Listening ' + midi_in_info + ' MIDI in(s).'
62 | else:
63 | midi_in_info = 'No available MIDI In found.'
64 | return midi_ins, midi_in_info
65 |
66 | def prepare_keyboard():
67 | keys = []
68 |
69 | class Key:
70 | pass
71 | for i in range(128):
72 | oct = i // 12 - 1
73 | note = i % 12
74 | black_key = note in [1, 3, 6, 8, 10]
75 | x = round(keyboard_margin_x + key_width * (oct * 7 + [0.5, 0.925, 1.5, 2.075, 2.5, 3.5, 3.85, 4.5, 5, 5.5, 6.15, 6.5][note] - 5))
76 | key= Key()
77 | key.note_no = i
78 | key.black_key = black_key
79 | key.x = x
80 | key.normalized_x = round(keyboard_margin_x + 0.5 * key_width + (key_width * 7) / 12 * (i - 21))
81 | key.note_on = False # Whether there is a sound including the damper pedal
82 | key.key_on = False # Whether the keyboard is pressed
83 | key.db = 0.0 # Virtual volume in DBs
84 | key.attack = 0.0 # Whether immediately after the attack. 1.0 indicates that it is just after the attack
85 | key.harmony = 0.0 # The volume of that harmony in DBs if the pitch is in harmony
86 | key.display_harmony = 0.0 # Display value with fluctuations added to the volume of the harmony
87 | keys.append(key)
88 | return keys
89 |
90 | def note_no_to_x(note_no):
91 | return keyboard_margin_x + 0.5 * key_width + (key_width * 7) / 12 * (note_no - 21) # 1 oct. lower
92 |
93 | def resource_path(relative_path):
94 | if hasattr(sys, '_MEIPASS'):
95 | return os.path.join(sys._MEIPASS, relative_path)
96 | return os.path.join(os.path.abspath("."), relative_path)
97 |
98 | def main():
99 | # initialization
100 | pygame.init()
101 | pygame.display.set_icon(pygame.image.load(resource_path('icon_128x128.png')))
102 | screen = pygame.display.set_mode(screen_size)
103 | full_screen = False
104 |
105 | pygame.display.set_caption(app_title)
106 |
107 | keyboard = prepare_keyboard()
108 | damper = False
109 | midi_ins, midi_in_info = prepare_midi_ins()
110 |
111 | tone_names_font = pygame.font.Font(None, base_size * 5)
112 | tone_names = ['C', '', 'D', '', 'E', 'F', '', 'G', '', 'A', '', 'B']
113 | tone_name_sizes = [tone_names_font.size(tone_name) for tone_name in tone_names]
114 | tone_names = [tone_names_font.render(tone_name, True, tone_color[i]) for i, tone_name in enumerate(tone_names)]
115 |
116 | # main_loop
117 | font = pygame.font.Font(None, base_size * 4)
118 | app_title_text = font.render(app_title + ' / Frieve 2022', True, (255,255,255))
119 | midi_info_text = font.render(midi_in_info, True, (255,255,255))
120 | terminated = False
121 | last_time = time.perf_counter() - 10
122 | harmony_radius = np.zeros(128)
123 | mouse_click_note = -1
124 | while (not terminated):
125 | current_time = time.perf_counter()
126 | last_wait_ms = (current_time - last_time) * 1000
127 | last_time = current_time
128 |
129 | # handle events
130 | for event in pygame.event.get():
131 | if event.type == QUIT:
132 | terminated = True
133 | elif event.type == KEYDOWN:
134 | if event.key == K_ESCAPE:
135 | terminated = True
136 | elif event.key == K_F11:
137 | full_screen = not full_screen
138 | if full_screen:
139 | screen = pygame.display.set_mode(screen_size, FULLSCREEN)
140 | else:
141 | screen = pygame.display.set_mode(screen_size)
142 | elif event.key == K_LCTRL or event.key == K_RCTRL:
143 | damper = True
144 | elif event.type == KEYUP:
145 | if event.key == K_LCTRL or event.key == K_RCTRL:
146 | damper = False
147 | elif event.type == MOUSEBUTTONDOWN and event.button == 1:
148 | for key in [key for key in keyboard[21:109] if key.black_key]:
149 | if event.pos[0] >= key.x - black_key_width / 2 and event.pos[0] < key.x + black_key_width / 2 and event.pos[1] >= keyboard_top and event.pos[1] < keyboard_top + black_key_height:
150 | mouse_click_note = key.note_no
151 | if mouse_click_note < 0:
152 | for key in [key for key in keyboard[21:109] if not key.black_key]:
153 | if event.pos[0] >= key.x - white_key_width / 2 and event.pos[0] < key.x + white_key_width / 2 and event.pos[1] >= keyboard_top and event.pos[1] < keyboard_top + white_key_height:
154 | mouse_click_note = key.note_no
155 | if mouse_click_note >= 0:
156 | keyboard[mouse_click_note].key_on = True
157 | keyboard[mouse_click_note].attack = 1.0
158 | keyboard[mouse_click_note].db = 96.0
159 |
160 | elif event.type == MOUSEBUTTONUP and event.button == 1:
161 | keyboard[mouse_click_note].key_on = False
162 | mouse_click_note = -1
163 |
164 | # midi in
165 | for midi_in in midi_ins:
166 | if midi_in.poll():
167 | midi_events = midi_in.read(88)
168 | for midi_event in midi_events:
169 | if midi_event[0][0] % 16 != 9: # ignore ch 10
170 | midi_event_type = midi_event[0][0] // 16
171 |
172 | if midi_event_type == 9 and midi_event[0][2] > 0: # note on
173 | keyboard[midi_event[0][1]].key_on = True
174 | keyboard[midi_event[0][1]].attack = 1.0
175 | keyboard[midi_event[0][1]].db = max(math.log(midi_event[0][2] / 127) / math.log(2.0) * 6.0 + 96.0, keyboard[midi_event[0][1]].db)
176 | elif midi_event_type == 8 or (midi_event_type == 9 and midi_event[0][2] == 0): # note off
177 | keyboard[midi_event[0][1]].key_on = False
178 | elif midi_event_type == 11:
179 | cc_no = midi_event[0][1]
180 | if cc_no == 64: # damper
181 | damper = midi_event[0][2] > 0
182 | # print(midi_event)
183 |
184 | # update keyboard status
185 | overtone_list = []
186 | harmony_buf = [[] for i in range(128)]
187 | harmony_list = []
188 | for key in keyboard:
189 | key.harmony = 0.0 # reset harmony volume
190 | for key in keyboard:
191 | key.note_on = key.key_on or (key.note_on and damper)
192 | key.db -= last_wait_ms * (0.003 if key.note_on else 0.024)
193 | if key.db < 0.0:
194 | key.db = 0
195 | else:
196 | processed_note_no = []
197 | for overtone_index in range(1, 384):
198 | energy = key.db - math.log(overtone_index) / math.log(2.0) * 3.0
199 | freq = key.note_no + math.log(overtone_index) / math.log(2.0) * 12
200 | if freq < min_note_no or energy < 60.0 or freq >= max_note_no:
201 | break
202 | overtone_list.append([key.note_no, freq, energy, key.attack])
203 |
204 | if overtone_index <= 10:
205 | overtone_note_no = int(round(freq))
206 | octave = overtone_index & (overtone_index - 1) == 0
207 | for harmony_note_no, harmony_overtone_index in harmony_buf[overtone_note_no]:
208 | harmony_octave = harmony_overtone_index & (harmony_overtone_index - 1) == 0
209 | if key.note_no % 12 != harmony_note_no % 12 and harmony_note_no not in processed_note_no and (not octave or not harmony_octave):# ignore octave
210 | brightness = min(key.db, keyboard[harmony_note_no].db) - 60
211 | keyboard[overtone_note_no].harmony = max(brightness, keyboard[overtone_note_no].harmony)
212 | if overtone_index > 1:
213 | harmony_list.append([key.note_no, overtone_index, brightness])
214 | if harmony_overtone_index > 1:
215 | harmony_list.append([harmony_note_no, harmony_overtone_index, brightness])
216 | processed_note_no.append(harmony_note_no)
217 | harmony_buf[overtone_note_no].append([key.note_no, overtone_index])
218 |
219 | key.attack = max(key.attack - last_wait_ms * 0.02, 0.0)
220 | overtone_list.sort(key = lambda x: x[2]) # sort with energy
221 | harmony_list.sort(key = lambda x: x[2]) # sort with brightness
222 |
223 | # fill screen
224 | screen.fill((0,0,0))
225 |
226 | # draw white keybed
227 | for key in [key for key in keyboard[21:109] if not key.black_key]:
228 | screen.fill((255, 255, 255) if not key.key_on else tone_color[key.note_no % 12], Rect(key.x - white_key_width // 2, keyboard_top, white_key_width, white_key_height - (base_size // 3 if key.attack > 0.0 else 0)))
229 | # draw black keybed
230 | for key in [key for key in keyboard[21:109] if key.black_key]:
231 | screen.fill((0, 0, 0), Rect(key.x - black_key_width // 2, keyboard_top, black_key_width, black_key_height))
232 | if key.key_on:
233 | screen.fill(tone_color[key.note_no % 12], Rect(key.x - black_key_width / 2 + line_width, keyboard_top, black_key_width - line_width * 2, black_key_height - line_width - (base_size // 3 if key.attack > 0.0 else 0)))
234 |
235 | # draw harmony volume
236 | for key in keyboard[21:109]:
237 | key.display_harmony = key.display_harmony * 0.05 + ((key.harmony + np.random.randn() * 6)* 0.95 if key.harmony > 0.0 else 0.0)
238 | if key.display_harmony > base_size:
239 | pygame.draw.circle(screen, (160, 160, 160), (key.normalized_x + 1, energy_bottom + 1), key.display_harmony * base_size / 24, line_width * 2)
240 |
241 | # draw harmony
242 | for i, (note_no, overtone_index, brightness) in enumerate(harmony_list):
243 | x1 = note_no_to_x(note_no)
244 | overtone_note_no = note_no + math.log(overtone_index) / math.log(2.0) * 12
245 | x2 = note_no_to_x(overtone_note_no)
246 | height = base_size * 4 + math.log(overtone_index) / math.log(2.0) * base_size * 20
247 | color = (tone_color[note_no % 12] * brightness / 36).astype(np.int32)
248 |
249 | point = []
250 | resolution = int(x2 - x1) // 6
251 | for i in range(resolution + 1):
252 | point.append((x1 + (x2 - x1) * i / resolution, energy_bottom - math.sqrt(math.sin(i / resolution * 3.1415926)) * height))
253 | pygame.draw.lines(screen, color, False, point, line_width)
254 |
255 | # draw overtone_index
256 | if i >= len(harmony_list) - 16:
257 | overtone_text = font.render(str(overtone_index), True, color)
258 | overtone_text_size = font.size(str(overtone_index))
259 | screen.blit(overtone_text, [(x1 + x2 - overtone_text_size[0]) // 2, energy_bottom - height - overtone_text_size[0] - base_size * 2])
260 |
261 | # draw overtone (linear)
262 | for note_no, freq, energy, attack in overtone_list:
263 | height = (energy - 60.0) * base_size
264 | x = note_no_to_x(freq)
265 | pygame.draw.line(screen, tone_color[note_no % 12], (x, energy_bottom - height), (x, energy_bottom), bold_line_width if attack > 0 else line_width)
266 |
267 | # draw key center dot
268 | for key in keyboard[21:109]:
269 | pygame.draw.circle(screen, (255, 255, 255), (key.normalized_x + 1, energy_bottom + 1), (3 * base_size) // 8, 0)
270 |
271 | # draw overtone (polar)
272 | for i in range(6):
273 | x = math.sin(i / 12 * math.pi * 2.0) * polar_overtone_radius
274 | y = math.cos(i / 12 * math.pi * 2.0) * polar_overtone_radius
275 | pygame.draw.line(screen, (64, 64, 64), (polar_overtone_center[0] + x, polar_overtone_center[1] - y), (polar_overtone_center[0] - x, polar_overtone_center[1] + y), line_width)
276 | pygame.draw.circle(screen, (96, 96, 96), polar_overtone_center, polar_overtone_radius, line_width)
277 | for i in range(12):
278 | x = math.sin(i / 12 * math.pi * 2.0) * (polar_overtone_radius + base_size * 3)
279 | y = math.cos(i / 12 * math.pi * 2.0) * (polar_overtone_radius + base_size * 3)
280 | screen.blit(tone_names[i], [polar_overtone_center[0] + x - tone_name_sizes[i][0] / 2, polar_overtone_center[1] - y - tone_name_sizes[i][0] / 2])
281 | for note_no, freq, energy, attack in overtone_list:
282 | r = (freq - 24) / (max_note_no - 24) * polar_overtone_radius
283 | angle = freq / 12
284 | pos = (polar_overtone_center[0] + math.sin(angle * math.pi * 2.0) * r + 1, polar_overtone_center[1] - math.cos(angle * math.pi * 2.0) * r + 1)
285 | if r > 0.0:
286 | radius = ((energy / 8 - 7 + attack * 2) * base_size) // 8
287 | if radius > 1:
288 | pygame.draw.circle(screen, tone_color[note_no % 12], pos, radius, 0)
289 | else:
290 | screen.set_at((int(pos[0]), int(pos[1])), tone_color[note_no % 12])
291 |
292 | # display info
293 | screen.blit(app_title_text, [keyboard_margin_x, keyboard_margin_x])
294 | screen.blit(midi_info_text, [keyboard_margin_x, keyboard_margin_x // 2 * 5])
295 |
296 | # draw
297 | pygame.display.update()
298 |
299 | # wait
300 | pygame.time.wait(10)
301 |
302 | for midi_in in midi_ins:
303 | midi_in.close()
304 | pygame.midi.quit()
305 | pygame.quit()
306 |
307 |
308 | if __name__ == "__main__":
309 | main()
310 |
--------------------------------------------------------------------------------
/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Frieve-A/harmony_visualizer/b492eb4bd25eb02adb3b94a020f7de371653cb5e/icon.ico
--------------------------------------------------------------------------------
/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Frieve-A/harmony_visualizer/b492eb4bd25eb02adb3b94a020f7de371653cb5e/icon.png
--------------------------------------------------------------------------------
/icon_128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Frieve-A/harmony_visualizer/b492eb4bd25eb02adb3b94a020f7de371653cb5e/icon_128x128.png
--------------------------------------------------------------------------------
/images/harmony_visualizer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Frieve-A/harmony_visualizer/b492eb4bd25eb02adb3b94a020f7de371653cb5e/images/harmony_visualizer.png
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | pygame
2 | numpy
3 |
--------------------------------------------------------------------------------