├── BpmAnalizer.py ├── ExtractBpmPatterns.py ├── LICENSE ├── README.md ├── UI.png ├── UserInterface.py ├── bpm.ico ├── bpm.png └── bpm_tray.png /BpmAnalizer.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import warnings 3 | 4 | warnings.filterwarnings("ignore", category=DeprecationWarning) 5 | warnings.filterwarnings("ignore", category=RuntimeWarning) 6 | from threading import Thread 7 | from time import sleep 8 | 9 | import link 10 | import numpy as np 11 | import pyaudio 12 | import PySimpleGUI as sg 13 | import rtmidi 14 | from psgtray import SystemTray 15 | from collections import deque 16 | import threading 17 | import struct 18 | import UserInterface 19 | import ExtractBpmPatterns 20 | import re 21 | import json 22 | import os 23 | from scipy import signal 24 | 25 | print("----") 26 | print("Live BPM Analyzer Version 2.0") 27 | print("© 2023 Matthias Schmid") 28 | print("----") 29 | 30 | FRAME_RATE = int(11025) 31 | 32 | try: 33 | BPM_PATTERN = np.load("bpm_pattern.npy") 34 | BPM_PATTERN_FINE = np.load("bpm_pattern_fine.npy") 35 | except FileNotFoundError: 36 | ExtractBpmPatterns.extract(FRAME_RATE) 37 | BPM_PATTERN = np.load("bpm_pattern.npy") 38 | BPM_PATTERN_FINE = np.load("bpm_pattern_fine.npy") 39 | 40 | 41 | class ThreadingEvents: 42 | def __init__(self): 43 | self.stop_analyzer = threading.Event() 44 | self.stop_trigger_set_bpm = threading.Event() 45 | self.stop_update_link_button = threading.Event() 46 | self.stop_refresh_main_window = threading.Event() 47 | self.bpm_updated = threading.Event() 48 | 49 | def stop_threads(self) -> None: 50 | self.stop_analyzer.set() 51 | self.stop_trigger_set_bpm.set() 52 | self.stop_update_link_button.set() 53 | self.stop_refresh_main_window.set() 54 | 55 | def start_update_link_button_thread(main_window: object, modules: object) -> None: 56 | Thread( 57 | target=UserInterface.update_link_button, 58 | args=(main_window, modules), 59 | ).start() 60 | 61 | def start_refresh_main_window_thread(main_window: object, modules: object) -> None: 62 | Thread( 63 | target=WindowReader.refresh_main_window, 64 | args=(main_window, modules), 65 | ).start() 66 | 67 | def start_trigger_set_bpm_thread(modules: object, user_mapping: object) -> None: 68 | Thread( 69 | target=modules.midi_interface.trigger_set_bpm, 70 | args=(modules, user_mapping), 71 | ).start() 72 | 73 | def start_run_analyzer_thread(modules: object) -> None: 74 | Thread( 75 | target=BpmAnalyzer.run_analyzer, args=(modules,), daemon=True 76 | ).start() 77 | 78 | 79 | class BpmStorage: 80 | def __init__(self): 81 | self._float = 120.00 # default 82 | self._str = "***.**" # default 83 | self.average_window = deque(maxlen=3) 84 | 85 | 86 | class AbletonLink: 87 | def __init__(self): 88 | self.link = link.Link(120.00) 89 | self.link.startStopSyncEnabled = True 90 | self.link.enabled = False 91 | 92 | def enable(self, bool: bool) -> None: 93 | self.link.enabled = bool 94 | 95 | def num_peers(self) -> int: 96 | return self.link.numPeers() 97 | 98 | def set_bpm(self, bpm: float) -> None: 99 | for value in [0.001, -0.001]: 100 | bpm += value 101 | s = self.link.captureSessionState() 102 | link_time = self.link.clock().micros() 103 | s.setTempo(bpm, link_time) 104 | self.link.commitSessionState(s) 105 | sleep(0.03) 106 | 107 | 108 | class AudioStreamer: 109 | def __init__(self, frame_rate: int, operating_range_seconds=12): 110 | self.frame_rate = frame_rate 111 | self.format = pyaudio.paInt16 112 | self.chunk = 10240 113 | self.audio = pyaudio.PyAudio() 114 | self.signal_buffer = deque(maxlen=int(frame_rate * operating_range_seconds)) 115 | self.operating_range_seconds = operating_range_seconds 116 | self.buffer_updated = threading.Event() 117 | self.stream = None 118 | 119 | def audio_callback(self, in_data: bytes, frame_count, time_info, status) -> None: 120 | num_int16_values = len(in_data) // 2 121 | signal_buffer_int = struct.unpack(f"<{num_int16_values}h", in_data) 122 | self.signal_buffer.extend(signal_buffer_int) 123 | self.buffer_updated.set() 124 | return (None, pyaudio.paContinue) 125 | 126 | def start_stream(self, input_device_index) -> None: 127 | self.stream = self.audio.open( 128 | format=self.format, 129 | channels=1, 130 | rate=self.frame_rate, 131 | input=True, 132 | frames_per_buffer=self.chunk, 133 | input_device_index=input_device_index, 134 | stream_callback=self.audio_callback, 135 | start=False, 136 | ) 137 | self.stream.start_stream() 138 | 139 | def get_buffer(self) -> np.ndarray: 140 | self.buffer_updated.wait() 141 | buffer = np.array(self.signal_buffer, dtype=np.int16) 142 | self.buffer_updated.clear() 143 | return buffer 144 | 145 | def stop_stream(self): 146 | self.stream.stop_stream() 147 | self.stream.close() 148 | self.audio.terminate() 149 | 150 | def available_audio_devices(self) -> list: 151 | devices = [] 152 | indices_of_devices = [] 153 | info = self.audio.get_host_api_info_by_index(0) 154 | numdevices = info.get("deviceCount") 155 | for i in range(0, numdevices): 156 | if ( 157 | self.audio.get_device_info_by_host_api_device_index(0, i).get( 158 | "maxInputChannels" 159 | ) 160 | ) > 0: 161 | device = self.audio.get_device_info_by_host_api_device_index(0, i).get( 162 | "name" 163 | ) 164 | index_of_device = self.audio.get_device_info_by_host_api_device_index( 165 | 0, i 166 | ).get("index") 167 | devices.append(device) 168 | indices_of_devices.append(index_of_device) 169 | return [devices, indices_of_devices] 170 | 171 | 172 | class BpmAnalyzer: 173 | def search_beat_events(signal_array: np.ndarray, frame_rate: int) -> np.ndarray: 174 | step_size = frame_rate // 2 175 | events = [] 176 | for step_start in range(0, len(signal_array), step_size): 177 | signal_array_window = signal_array[step_start : step_start + step_size] 178 | signal_array_window[signal_array_window < signal_array_window.max()] = 0 179 | signal_array_window[signal_array_window > 0] = 1 180 | event = np.argmax(signal_array_window) + step_start 181 | events.append(event) 182 | return np.array(events, dtype=np.int64) 183 | 184 | def bpm_container(beat_events: np.ndarray, bpm_pattern: np.ndarray, steps: int) -> list[list]: 185 | bpm_container = [list(np.zeros((1,), dtype=np.int64))for _ in range(beat_events.size * steps)] 186 | for i, beat_event in enumerate(beat_events): 187 | found_in_pattern = np.where(np.logical_and(bpm_pattern >= beat_event - 20, bpm_pattern <= beat_event + 20)) 188 | for x, q in enumerate(found_in_pattern[0]): 189 | bpm_container[i * steps + q].append(found_in_pattern[1][x]) 190 | return bpm_container 191 | 192 | def wrap_bpm_container(bpm_container: list, steps: int) -> list[list]: 193 | def flatten(input_list: list) -> list: 194 | return [item for sublist in input_list for item in sublist] 195 | 196 | bpm_container_wrapped = [list(np.zeros((1,), dtype=np.int64)) for _ in range(steps)] 197 | for i, w in enumerate(bpm_container_wrapped): 198 | w.extend(flatten(bpm_container[i::steps])) 199 | w.remove(0) 200 | bpm_container_wrapped[i] = list(filter(lambda num: num != 0, w)) 201 | return bpm_container_wrapped 202 | 203 | def finalise_bpm_container(bpm_container_wrapped: list, steps: int) -> np.ndarray: 204 | bpm_container_final = np.zeros((steps, 1), dtype=np.int64) 205 | for i, w in enumerate(bpm_container_wrapped): 206 | values, counts = np.unique(w, return_counts=True) 207 | values = values[counts == counts.max()] 208 | if values[0] > 0: 209 | count = np.count_nonzero(w == values[0]) 210 | bpm_container_final[i] = count 211 | return bpm_container_final 212 | 213 | def get_bpm_wrapped(bpm_container_final: np.ndarray) -> np.ndarray: 214 | return np.where(bpm_container_final == np.amax(bpm_container_final)) 215 | 216 | def check_bpm_wrapped(bpm_wrapped: np.ndarray, bpm_container_final: np.ndarray) -> bool: 217 | count = np.count_nonzero(bpm_container_final == bpm_wrapped[0][0]) 218 | if count > 1 or bpm_container_final[int(bpm_wrapped[0][0])] < 6: 219 | return 0 220 | else: 221 | return 1 222 | 223 | def get_bpm_pattern_fine_window(bpm_wrapped: np.ndarray) -> int: 224 | start = int(((bpm_wrapped[0][0] / 4) / 0.05) - 20) 225 | end = int(start + 40) 226 | return start, end 227 | 228 | def bpm_wrapped_to_float_str(bpm: np.ndarray, bpm_fine: np.ndarray) -> float: 229 | bpm_float = round( 230 | float((((bpm[0][0] / 4) + 100) - 1) + (bpm_fine[0][0] * 0.05)), 2 231 | ) 232 | bpm_str = format(bpm_float, ".2f") 233 | return bpm_float, bpm_str 234 | 235 | def search_bpm(signal_array: np.ndarray, frame_rate: int) -> tuple: 236 | bpm_pattern = BPM_PATTERN 237 | bpm_pattern_fine = BPM_PATTERN_FINE 238 | beat_events = BpmAnalyzer.search_beat_events(signal_array, frame_rate) 239 | for switch_pattern in [240, 40]: 240 | bpm_container = BpmAnalyzer.bpm_container( 241 | beat_events, bpm_pattern, switch_pattern 242 | ) 243 | bpm_container_wrapped = BpmAnalyzer.wrap_bpm_container( 244 | bpm_container, switch_pattern 245 | ) 246 | try: 247 | bpm_container_final = BpmAnalyzer.finalise_bpm_container( 248 | bpm_container_wrapped, switch_pattern 249 | ) 250 | except ValueError: 251 | return 0 252 | bpm_wrapped = BpmAnalyzer.get_bpm_wrapped(bpm_container_final) 253 | if not BpmAnalyzer.check_bpm_wrapped(bpm_wrapped, bpm_container_final): 254 | return 0 255 | if switch_pattern == 240: 256 | start, end = BpmAnalyzer.get_bpm_pattern_fine_window(bpm_wrapped) 257 | bpm_pattern = bpm_pattern_fine[start:end] 258 | bpm_wrapped_full_range = bpm_wrapped 259 | else: 260 | bpm_wrapped_fine_range = bpm_wrapped 261 | return BpmAnalyzer.bpm_wrapped_to_float_str( 262 | bpm_wrapped_full_range, bpm_wrapped_fine_range 263 | ) 264 | 265 | def run_analyzer(modules: object) -> None: 266 | while not modules.threading_events.stop_analyzer.is_set(): 267 | buffer = modules.audio_streamer.get_buffer() 268 | buffer = bandpass_filter(buffer) 269 | if bpm_float_str := BpmAnalyzer.search_bpm(buffer, FRAME_RATE): 270 | modules.bpm_storage.average_window.append(bpm_float_str[0]) 271 | bpm_average = round( 272 | ( 273 | sum(modules.bpm_storage.average_window) 274 | / len(modules.bpm_storage.average_window) 275 | ), 276 | 2, 277 | ) 278 | ( 279 | modules.bpm_storage._float, 280 | modules.bpm_storage._str, 281 | ) = bpm_average, format(bpm_average, ".2f") 282 | 283 | 284 | class MidiInterface: 285 | def __init__(self): 286 | self.midi_in = rtmidi.MidiIn() 287 | self.midi_out = rtmidi.MidiOut() 288 | 289 | def get_available_devices(self): 290 | available_devices = { 291 | "midi_devices_in": [], 292 | "midi_devices_out": [], 293 | "midi_devices_in_str": [], 294 | "midi_devices_out_str": [], 295 | } 296 | for midi_device in self.midi_in.get_ports(): 297 | if matches := re.search(r"(.+) ([0-9]+)", midi_device): 298 | available_devices["midi_devices_in"].append( 299 | {matches.group(1): matches.group(2)} 300 | ) 301 | available_devices["midi_devices_in_str"].append(matches.group(1)) 302 | for midi_device in self.midi_out.get_ports(): 303 | if matches := re.search(r"(.+) ([0-9]+)", midi_device): 304 | available_devices["midi_devices_out"].append( 305 | {matches.group(1): matches.group(2)} 306 | ) 307 | available_devices["midi_devices_out_str"].append(matches.group(1)) 308 | return available_devices 309 | 310 | def set_in_device(self, choosen_midi_device_in: str, midi_devices: dict[str, list]): 311 | self.midi_in.close_port() 312 | for midi_device in midi_devices: 313 | if choosen_midi_device_in in midi_device: 314 | try: 315 | self.midi_in.open_port(int(midi_device[choosen_midi_device_in])) 316 | except: pass 317 | 318 | def set_out_device(self, choosen_midi_device_out: str, midi_devices: dict[str, list]): 319 | self.midi_out.close_port() 320 | for midi_device in midi_devices: 321 | if choosen_midi_device_out in midi_device: 322 | try: 323 | self.midi_out.open_port(int(midi_device[choosen_midi_device_out])) 324 | except: pass 325 | 326 | def learn(self): 327 | count = 0 328 | while True: 329 | sleep(0.2) 330 | midi_in_msg = self.midi_in.get_message() 331 | if midi_in_msg == None: 332 | if count >= 1: 333 | if midi_in_msg == None: 334 | break 335 | else: 336 | count =+ 1 337 | if count == 1: 338 | user_mapping = str(midi_in_msg) 339 | try: 340 | return convert_midi_msg(user_mapping) 341 | except: pass 342 | 343 | def trigger_set_bpm(self, modules: object, user_mapping): 344 | while not modules.threading_events.stop_trigger_set_bpm.is_set(): 345 | sleep(0.02) 346 | midi_in_msg = str(self.midi_in.get_message()) 347 | try: 348 | midi_in_msg = convert_midi_msg(midi_in_msg) 349 | except: pass 350 | if midi_in_msg == user_mapping: 351 | modules.ableton_link.set_bpm(modules.bpm_storage._float) 352 | 353 | def close_ports(self): 354 | self.midi_in.close_port 355 | self.midi_out.close_port 356 | 357 | 358 | class WindowReader: 359 | def audio_device_selection(choose_input_window: object, audio_devices: list) -> int: 360 | def get_choosen_audio_device(values, audio_devices): 361 | audio_device = values["board"] 362 | index_for_device = audio_devices[0].index(audio_device) 363 | return audio_devices[1][index_for_device] 364 | 365 | choose_input_window.un_hide() 366 | while True: 367 | event, values = choose_input_window.read() 368 | if event == sg.WIN_CLOSED: 369 | break 370 | if event == "Next": 371 | break 372 | if event == "board": 373 | choosen_audio_device = get_choosen_audio_device(values, audio_devices) 374 | choose_input_window["Next"].update(disabled=False) 375 | choose_input_window.hide() 376 | return choosen_audio_device 377 | 378 | def midi_device_selection( 379 | modules: object, 380 | midi_device_selection_window: object, 381 | midi_devices: dict[str, list], 382 | ): 383 | midi_device_selection_window.un_hide() 384 | set_in = False 385 | set_out = False 386 | while True: 387 | event, values = midi_device_selection_window.read() 388 | sleep(0.02) 389 | if event == sg.WIN_CLOSED: 390 | midi_device_selection_window.close() 391 | break 392 | if event == "Exit": 393 | midi_device_selection_window.close() 394 | break 395 | if event == "learnsendbpm": 396 | midi_device_selection_window.Element("learnsendbpm").Update( 397 | button_color="black on white" 398 | ) 399 | user_mapping = modules.midi_interface.learn() 400 | midi_device_selection_window.close() 401 | return user_mapping 402 | if event == "midiinput": 403 | choosen_device = values["midiinput"] 404 | modules.midi_interface.set_in_device( 405 | choosen_device, midi_devices["midi_devices_in"] 406 | ) 407 | set_in = True 408 | if set_out == True: 409 | midi_device_selection_window.Element("learnsendbpm").Update( 410 | disabled=False 411 | ) 412 | if event == "midioutput": 413 | choosen_device = values["midioutput"] 414 | modules.midi_interface.set_out_device( 415 | choosen_device, midi_devices["midi_devices_out"] 416 | ) 417 | set_out = True 418 | if set_in == True: 419 | midi_device_selection_window.Element("learnsendbpm").Update( 420 | disabled=False 421 | ) 422 | 423 | def midi_device_selection_done(midi_device_selection_done_window: object): 424 | midi_device_selection_done_window.un_hide() 425 | while True: 426 | event, _ = midi_device_selection_done_window.read() 427 | sleep(0.02) 428 | if event == sg.WIN_CLOSED: 429 | midi_device_selection_done_window.close() 430 | break 431 | if event == "Exit": 432 | midi_device_selection_done_window.close() 433 | break 434 | 435 | def main_window(main_window: object, modules: object): 436 | menu = ["", ["Show Window"]] 437 | tray = SystemTray( 438 | menu=menu, 439 | single_click_events=True, 440 | window=main_window, 441 | tooltip="Live BPM Analyzer", 442 | icon="./bpm_tray.png", 443 | ) 444 | switch_button = True 445 | while True: 446 | event, _ = main_window.read() 447 | sleep(0.02) 448 | if event == tray.key: 449 | main_window.BringToFront() 450 | if event == sg.WIN_CLOSED: 451 | modules.threading_events.stop_threads() 452 | modules.ableton_link.enable(False) 453 | tray.close() 454 | main_window.close() 455 | return 0 456 | if event == "link": 457 | switch_button = not switch_button 458 | main_window.Element("link").Update( 459 | ("LINK", "LINK")[switch_button], 460 | button_color=(("white on blue", "black on white")[switch_button]), 461 | ) 462 | if switch_button == True: 463 | modules.threading_events.stop_update_link_button.set() 464 | modules.ableton_link.enable(False) 465 | if switch_button == False: 466 | modules.ableton_link.enable(True) 467 | ThreadingEvents.start_update_link_button_thread(main_window, modules) 468 | if event == "settings": 469 | modules.threading_events.stop_threads() 470 | modules.ableton_link.enable(False) 471 | tray.close() 472 | main_window.close() 473 | return 1 474 | if event == "sendbpm": 475 | modules.ableton_link.set_bpm(modules.bpm_storage._float) 476 | 477 | def refresh_main_window(main_window: object, modules: object) -> None: 478 | while not modules.threading_events.stop_refresh_main_window.is_set(): 479 | sleep(1) 480 | main_window["bpm"].update(modules.bpm_storage._str) 481 | 482 | 483 | class OpenWindow: 484 | def __init__(self): 485 | self.resolution = UserInterface.check_screen_resolution() 486 | 487 | def audio_device_selection(self, modules: object) -> int: 488 | audio_devices = modules.audio_streamer.available_audio_devices() 489 | audio_device_selection_window = UserInterface.audio_device_selection( 490 | audio_devices, self.resolution 491 | ) 492 | choosen_audio_device = WindowReader.audio_device_selection( 493 | audio_device_selection_window, audio_devices 494 | ) 495 | Settings.save(choosen_audio_device=choosen_audio_device) 496 | return choosen_audio_device 497 | 498 | def midi_device_selection(self, modules: object) -> str: 499 | midi_devices = modules.midi_interface.get_available_devices() 500 | midi_device_selection_window = UserInterface.midi_device_selection( 501 | midi_devices["midi_devices_in_str"], 502 | midi_devices["midi_devices_out_str"], 503 | self.resolution, 504 | ) 505 | if user_mapping := WindowReader.midi_device_selection( 506 | modules, midi_device_selection_window, midi_devices 507 | ): 508 | midi_device_selection_window_done = ( 509 | UserInterface.midi_device_selection_done(self.resolution) 510 | ) 511 | WindowReader.midi_device_selection_done(midi_device_selection_window_done) 512 | Settings.save(user_mapping=user_mapping) 513 | return user_mapping 514 | 515 | def main_window(self, modules) -> None: 516 | main_window = UserInterface.main_window(self.resolution) 517 | ThreadingEvents.start_refresh_main_window_thread(main_window, modules) 518 | if WindowReader.main_window(main_window, modules): 519 | return 1 520 | else: 521 | return 0 522 | 523 | 524 | class Settings: 525 | def check() -> bool: 526 | try: 527 | with open("settings.json", "r") as _: 528 | pass 529 | return 1 530 | except: 531 | content = {"choosen_audio_device": "", "user_mapping": ""} 532 | with open("settings.json", "w") as settings: 533 | json.dump(content, settings) 534 | return 0 535 | 536 | def save(choosen_audio_device=None, user_mapping=None) -> None: 537 | with open("settings.json", "r") as settings: 538 | content = json.load(settings) 539 | if choosen_audio_device is not None: 540 | content["choosen_audio_device"] = choosen_audio_device 541 | if user_mapping is not None: 542 | content["user_mapping"] = user_mapping 543 | with open("settings.json", "w") as settings: 544 | json.dump(content, settings) 545 | 546 | def open() -> None: 547 | settings_lst = [] 548 | with open("settings.json", "r") as settings: 549 | data = json.load(settings) 550 | for key, value in data.items(): 551 | settings_lst.append(value) 552 | return settings_lst 553 | 554 | 555 | class InitialiseModules: 556 | def __init__(self): 557 | self.bpm_storage = BpmStorage() 558 | self.threading_events = ThreadingEvents() 559 | self.audio_streamer = AudioStreamer(FRAME_RATE) 560 | self.ableton_link = AbletonLink() 561 | self.midi_interface = MidiInterface() 562 | self.open_window = OpenWindow() 563 | 564 | 565 | def convert_midi_msg(msg) -> str: 566 | for char in "()[]": 567 | msg = msg.replace(char, "") 568 | msg = msg.split(",", -1) 569 | del msg[3] 570 | for i, value in enumerate(msg): 571 | msg[i] = value.strip() 572 | return msg 573 | 574 | 575 | def bandpass_filter(audio_signal, lowcut=60.0, highcut=3000.0) -> np.ndarray: 576 | def butter_bandpass(lowcut, highcut, fs, order=10): 577 | nyq = 0.5 * fs 578 | low = lowcut / nyq 579 | high = highcut / nyq 580 | b, a = signal.butter(order, [low, high], btype='band') 581 | return b, a 582 | 583 | def butter_bandpass_filter(data, lowcut, highcut, fs, order=10): 584 | b, a = butter_bandpass(lowcut, highcut, fs, order=order) 585 | y = signal.lfilter(b, a, data) 586 | return y 587 | 588 | def _bandpass_filter(buffer): 589 | return butter_bandpass_filter(buffer, lowcut, highcut, FRAME_RATE, order=6) 590 | 591 | return np.apply_along_axis(_bandpass_filter, 0, audio_signal).astype('int16') 592 | 593 | 594 | def main() -> None: 595 | while True: 596 | modules = InitialiseModules() 597 | if not Settings.check(): 598 | choosen_audio_device = modules.open_window.audio_device_selection(modules) 599 | user_mapping = modules.open_window.midi_device_selection(modules) 600 | else: 601 | settings = Settings.open() 602 | choosen_audio_device, user_mapping = int(settings[0]), settings[1] 603 | modules.audio_streamer.start_stream(choosen_audio_device) 604 | ThreadingEvents.start_trigger_set_bpm_thread(modules, user_mapping) 605 | ThreadingEvents.start_run_analyzer_thread(modules) 606 | if modules.open_window.main_window(modules): # Main loop 607 | modules.audio_streamer.stop_stream() 608 | modules.midi_interface.close_ports 609 | ThreadingEvents.stop_threads 610 | os.remove("settings.json") 611 | else: 612 | modules.audio_streamer.stop_stream() 613 | modules.midi_interface.close_ports 614 | ThreadingEvents.stop_threads 615 | sys.exit() 616 | 617 | 618 | if __name__ == "__main__": 619 | main() 620 | -------------------------------------------------------------------------------- /ExtractBpmPatterns.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | def extract_bpm_pattern(lengh: int, frame_rate: int) -> None: 5 | array = np.full((240, lengh, 32), 0, dtype=np.int64) 6 | jump = int(0) 7 | add = 0 8 | 9 | for i in range(240): 10 | add += 0.25 11 | timestamp = int(60 / (100 + add) * frame_rate) 12 | jump = int(0) 13 | for x in range(lengh): 14 | timestamp_next = 0 15 | jump += 20 16 | for y in range(32): 17 | array[i][x][y] = timestamp_next 18 | timestamp_next += timestamp 19 | array[i][x] = array[i][x] + jump 20 | 21 | np.save("bpm_pattern.npy", array) 22 | 23 | 24 | def extract_bpm_pattern_fine(lengh: int, frame_rate: int) -> None: 25 | array = np.full((1200, lengh, 32), 0, dtype=np.int64) 26 | jump = int(0) 27 | add = 0 28 | 29 | for i in range(1200): 30 | timestamp = int(60 / (100 + add) * frame_rate) 31 | add += 0.05 32 | jump = int(0) 33 | for x in range(lengh): 34 | timestamp_next = 0 35 | jump += 20 36 | for y in range(32): 37 | array[i][x][y] = timestamp_next 38 | timestamp_next += timestamp 39 | array[i][x] = array[i][x] + jump 40 | 41 | np.save("bpm_pattern_fine.npy", array) 42 | 43 | 44 | def extract(frame_rate: int): 45 | print("PATTERN CREATOR") 46 | print("extracting...") 47 | lengh = int((frame_rate / 2) / 20) 48 | extract_bpm_pattern(lengh, frame_rate) 49 | extract_bpm_pattern_fine(lengh, frame_rate) 50 | print("\033[92m" + "COMPLETED" + "\033[0m") 51 | 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Tempo analyzer for music. 2 | Copyright (C) 2023 Matthias Christopher Schmid 3 | 4 | This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. 5 | 6 | This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. 7 | 8 | You should have received a copy of the GNU General Public License along with this program. If not, see . 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![plot](./bpm.png) BpmAnalyzer 2 |
3 |

4 | A BPM analyzer designed for live musicians using DAWs like Ableton or VJs who want to collaborate with other artists and focus more on performance instead of wasting time finding the right tempo of a source that cannot be digitally synced. The operating range is currently set between 110-160 BPM. 5 |

6 |
7 | 8 |

9 | 10 |

11 | 12 | Advantages: 13 | 14 | - Very accurate regardless of the music genre (+/- 0.10) 15 | - Ableton Link is integrated, allowing connection to a wide range of VJ software, DAWs, and more. 16 | - MIDI mapping implementation for sending the BPM to connected programs. 17 | - Works with low-quality signals, such as a microphone input. 18 | 19 | Installation: 20 | 21 | 1. Download all files in the repository and save them in a new folder. 22 | 2. cd into the new folder via command line 23 | 3. Run BpmAnalyzer.py from the command line. 24 | 4. For a compiled version (.exe), please contact me. 25 | -------------------------------------------------------------------------------- /UI.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MatthiasSchmid93/BpmAnalyzer/f4b7f775a7775f8c1f09f1f1c69b95de33d76153/UI.png -------------------------------------------------------------------------------- /UserInterface.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import QApplication 2 | import PySimpleGUI as sg 3 | import sys 4 | import tkinter as tk 5 | from time import sleep 6 | 7 | sg.theme("Black") 8 | sg.set_options(dpi_awareness=True) 9 | 10 | 11 | # switch object sizes of the ui for low resolution or high resolution screens 12 | class win_lay: 13 | in_win_lst = [[25, 27], [50, 27]] 14 | in_win_win = [[280, 139], [600, 200]] 15 | main_win_win = [[590, 230], [1150, 435]] 16 | main_win_txt_bpm = [[10], [40]] 17 | main_win_txt_bpmind = [[10], [20]] 18 | main_win_txt_info = [[5], [20]] 19 | main_win_bar_size = [[52, 3], [86, 5]] 20 | main_win_bar_pad = [[10, 10], [20, 20]] 21 | main_win_but_start = [[10], [20]] 22 | main_win_but_once = [[5], [20]] 23 | main_win_but_link = [[10], [20]] 24 | main_win_but_learn = [[5], [20]] 25 | make_win_txt_setup = [[100, 15], [211, 20]] 26 | make_win_lstin = [[25, 10], [50, 20]] 27 | make_win_lstout = [[25, 10], [50, 30]] 28 | make_win_txt_info = [[5], [20]] 29 | make_win_but_learn_pad = [[5], [50]] 30 | make_win_but_learn_size = [[15], [18]] 31 | make_win_but_exit = [[10], [35]] 32 | make_win_win = [[300, 185], [600, 360]] 33 | 34 | 35 | def check_screen_resolution() -> int: 36 | app = QApplication(sys.argv) 37 | screen = app.screens()[0] 38 | dpi = screen.physicalDotsPerInch() 39 | app.quit() 40 | if dpi > 150: 41 | return int(1) 42 | else: 43 | return int(0) 44 | 45 | 46 | def audio_device_selection(audio_devices: list, resolution: int) -> sg.Window: 47 | layout = [ 48 | [ 49 | sg.Combo( 50 | audio_devices[0], 51 | background_color="white", 52 | text_color="black", 53 | default_value="Choose Audio Input...", 54 | key="board", 55 | enable_events=True, 56 | readonly=True, 57 | pad=( 58 | win_lay.in_win_lst[resolution][0], 59 | win_lay.in_win_lst[resolution][1], 60 | ), 61 | ) 62 | ], 63 | [ 64 | sg.Button( 65 | key="Next", 66 | button_text="NEXT", 67 | border_width=0, 68 | size=(7, 1), 69 | disabled=True, 70 | focus=True, 71 | pad=(15, 0), 72 | ) 73 | ], 74 | ] 75 | 76 | window = sg.Window( 77 | "Live BPM Analyzer", 78 | layout, 79 | no_titlebar=False, 80 | titlebar_icon="./bpm.png", 81 | finalize=True, 82 | size=(win_lay.in_win_win[resolution][0], win_lay.in_win_win[resolution][1]), 83 | titlebar_text_color="#ffffff", 84 | use_custom_titlebar=True, 85 | titlebar_background_color="#000000", 86 | titlebar_font=("Arial", 12), 87 | ) 88 | window.set_icon("./bpm.ico") 89 | return window 90 | 91 | 92 | def midi_device_selection( 93 | available_ports_in, available_ports_out, resolution: int 94 | ) -> sg.Window: 95 | layout = [ 96 | [ 97 | sg.Text( 98 | "MIDI SETUP", 99 | background_color="blue", 100 | pad=( 101 | win_lay.make_win_txt_setup[resolution][0], 102 | win_lay.make_win_txt_setup[resolution][1], 103 | ), 104 | ) 105 | ], 106 | [ 107 | sg.Combo( 108 | available_ports_in, 109 | default_value="choose midi input...", 110 | pad=( 111 | win_lay.make_win_lstin[resolution][0], 112 | win_lay.make_win_lstin[resolution][1], 113 | ), 114 | size=(31, 1), 115 | background_color="white", 116 | text_color="black", 117 | key="midiinput", 118 | enable_events=True, 119 | readonly=True, 120 | ) 121 | ], 122 | [ 123 | sg.Combo( 124 | available_ports_out, 125 | default_value="choose midi output...", 126 | pad=( 127 | win_lay.make_win_lstout[resolution][0], 128 | win_lay.make_win_lstout[resolution][1], 129 | ), 130 | size=(31, 1), 131 | background_color="white", 132 | text_color="black", 133 | key="midioutput", 134 | enable_events=True, 135 | readonly=True, 136 | ) 137 | ], 138 | [ 139 | sg.Text( 140 | key="infomidi", 141 | pad=(win_lay.make_win_txt_info[resolution][0], 1), 142 | font=("", 9), 143 | background_color="blue", 144 | text_color="black", 145 | ) 146 | ], 147 | [ 148 | sg.Button( 149 | key="learnsendbpm", 150 | border_width=0, 151 | disabled=True, 152 | button_text="LEARN SEND BPM", 153 | pad=(win_lay.make_win_but_learn_pad[resolution][0], 1), 154 | size=(win_lay.make_win_but_learn_size[resolution][0], 1), 155 | ), 156 | sg.Button( 157 | key="Exit", 158 | border_width=0, 159 | button_text="EXIT", 160 | pad=(win_lay.make_win_but_exit[resolution][0], 1), 161 | size=(6, 1), 162 | ), 163 | ], 164 | ] 165 | return sg.Window( 166 | "title", 167 | layout, 168 | finalize=True, 169 | no_titlebar=True, 170 | background_color="blue", 171 | size=(win_lay.make_win_win[resolution][0], win_lay.make_win_win[resolution][1]), 172 | grab_anywhere=True, 173 | titlebar_icon="./bpm.png", 174 | ) 175 | 176 | 177 | def midi_device_selection_done(resolution: int) -> sg.Window: 178 | layout = [ 179 | [ 180 | sg.Text( 181 | "MIDI SETUP DONE", 182 | background_color="blue", 183 | pad=( 184 | win_lay.make_win_txt_setup[resolution][0], 185 | win_lay.make_win_txt_setup[resolution][1], 186 | ), 187 | ) 188 | ], 189 | [ 190 | sg.Button( 191 | key="Exit", 192 | border_width=0, 193 | button_text="OK", 194 | pad=(win_lay.make_win_but_exit[resolution][0], 1), 195 | size=(6, 1), 196 | ), 197 | ], 198 | ] 199 | return sg.Window( 200 | "title", 201 | layout, 202 | no_titlebar=True, 203 | finalize=True, 204 | background_color="blue", 205 | size=(win_lay.make_win_win[resolution][0], win_lay.make_win_win[resolution][1]), 206 | grab_anywhere=True, 207 | titlebar_icon="./bpm.png", 208 | ) 209 | 210 | 211 | def main_window(resolution: int) -> sg.Window: 212 | layout = [ 213 | [ 214 | sg.Text( 215 | "***.**", 216 | key="bpm", 217 | font=("", 77), 218 | pad=(win_lay.main_win_txt_bpm[resolution][0], 1), 219 | ), 220 | sg.Text( 221 | "BPM", 222 | key="bpmindicate", 223 | font=("", 26), 224 | text_color="blue", 225 | pad=(win_lay.main_win_txt_bpmind[resolution][0], 1), 226 | ), 227 | ], 228 | [ 229 | sg.Text( 230 | key="info", 231 | pad=(win_lay.main_win_txt_info[resolution][0], 1), 232 | font=("", 9), 233 | ) 234 | ], 235 | [ 236 | sg.ProgressBar( 237 | 150, 238 | orientation="h", 239 | size=( 240 | win_lay.main_win_bar_size[resolution][0], 241 | win_lay.main_win_bar_size[resolution][1], 242 | ), 243 | pad=( 244 | win_lay.main_win_bar_pad[resolution][0], 245 | win_lay.main_win_bar_pad[resolution][1], 246 | ), 247 | border_width=0, 248 | key="-PROGRESS_BAR-", 249 | bar_color=("Blue", "Blue"), 250 | ) 251 | ], 252 | [ 253 | sg.Button( 254 | key="link", 255 | button_text="LINK", 256 | border_width=0, 257 | size=(7, 1), 258 | pad=(win_lay.main_win_but_link[resolution][0], 2), 259 | ), 260 | sg.Button( 261 | key="sendbpm", 262 | button_text="SEND BPM", 263 | border_width=0, 264 | size=(11, 1), 265 | button_color="black on white", 266 | ), 267 | sg.Button( 268 | key="settings", 269 | button_text="SETTINGS", 270 | border_width=0, 271 | pad=(win_lay.main_win_but_learn[resolution][0], 2), 272 | size=(10, 1), 273 | ), 274 | ], 275 | ] 276 | return sg.Window( 277 | "Live BPM Analyzer", 278 | layout, 279 | no_titlebar=False, 280 | finalize=True, 281 | titlebar_icon="./bpm.png", 282 | size=(win_lay.main_win_win[resolution][0], win_lay.main_win_win[resolution][1]), 283 | titlebar_text_color="#ffffff", 284 | use_custom_titlebar=True, 285 | titlebar_background_color="#000000", 286 | titlebar_font=("Arial", 12), 287 | grab_anywhere=True, 288 | icon="./bpm.ico", 289 | ) 290 | 291 | 292 | def update_link_button(main_window: object, modules: object) -> None: 293 | isOn = 0 294 | button_info = { 295 | 0: ("LINK", "white on blue"), 296 | 1: ("1 LINK", "white on blue"), 297 | 2: ("2 LINKS", "white on blue"), 298 | 3: ("3 LINKS", "white on blue"), 299 | } 300 | while True: 301 | if main_window == sg.WIN_CLOSED: 302 | break 303 | if modules.ableton_link.link.enabled == False: 304 | break 305 | peers = ( 306 | modules.ableton_link.num_peers() 307 | if modules.ableton_link.link.enabled == True 308 | else peers 309 | ) 310 | if peers in button_info and peers != isOn: 311 | main_window.Element("link").Update( 312 | button_info[peers][0], button_color=button_info[peers][1] 313 | ) 314 | isOn = peers 315 | sleep(0.04) 316 | -------------------------------------------------------------------------------- /bpm.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MatthiasSchmid93/BpmAnalyzer/f4b7f775a7775f8c1f09f1f1c69b95de33d76153/bpm.ico -------------------------------------------------------------------------------- /bpm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MatthiasSchmid93/BpmAnalyzer/f4b7f775a7775f8c1f09f1f1c69b95de33d76153/bpm.png -------------------------------------------------------------------------------- /bpm_tray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MatthiasSchmid93/BpmAnalyzer/f4b7f775a7775f8c1f09f1f1c69b95de33d76153/bpm_tray.png --------------------------------------------------------------------------------