zak-45 / WLEDAudioSyncRTBeat

Real Time beat detection using aubio. Send BPM to OSC.
MIT License
10 stars 1 forks source link

Some Rework needed for "Different BPM-Values for GrandMA3" #2

Closed salopeknet closed 6 months ago

salopeknet commented 6 months ago

Hey zak-45,

thank you for the really quick implementation of #1 !!!

Generally it works great for GrandMA3 if I use following command line options: beat --server 127.0.0.1 8000 /gMA3/Page3/Fader212 --custom GMA3 --verbose

The GrandMA captures the correct BPM-values! Great job!

But: Did you recalculate the whole output-value coming from the backend with the given formula? I noticed, that the output in the shell are not the actual counted BMP, but the calculated value in percent, the --custom parameter seems to work globally.

If i try to send the BPM to another target like "MadMapper" (visualisation software) in the same process, MadMapper also gets the BPM in percent, but it needs the plain value.

Wouldn't it be better if you just re-calculated the output-value just before it gets sent to each OSC-target and the --custom parameter is set just for each --server, not globally? A neat side-effect would be that --verbose always would show the actual BPM-value in stdout...

Perhaps it would be better to skip the --custom attribute again and add another value to --server like TYPE which could be "GMA3" or "PLAIN" (always defaults to "PLAIN" if omitted?) so I could use following commandline option to send to GMA3 and MadMapper from one process: beat --server GMA3 127.0.0.1 8000 /gMA3/Page3/Fader212 --server PLAIN 127.0.0.1 8010 /MadMapper/GlobalBPM --verbose

Perhaps you have another idea?

Yours sincerily, Micha

zak-45 commented 6 months ago

Hi, I see but that would complicate a bit. You can always run another process, this do not take so much ressources. On my side I do not need this feature, will put it on to do list. Closed this one at this time, but keep it on mind. Have a nice day.

salopeknet commented 6 months ago

Thanks for your reply! Its not about the resources, but more about absolute synchronisation of the BPM between all paricipating OSC-targets... I think, I will need to third software soon I did not mention for now... :)

I don't know if every process would count exactly the same BPM-value, but I'll do some testing.

So, if you could keep it in mind and you find the time somewhen, I would appreciate that really much...

Thanks! You too.

salopeknet commented 6 months ago

Okay, i took a look into your code and I think I understand it, even as I am not used with Python...

I swapped if args.custom and if args.verbose in your code so that always the plain BPM-Value is printed on stdout... Perhaps you could release this as a small bugfix? :)

But I tried some more... I digged into it and managed to add one more value to "--server" called "MODE" which can be "PLAIN" (sends directly the counted BPM-Value), "HALF" (sends half of the counted BPM, sometimes useful) or "GMA3" (sends the calculated value for GMA3-SpeedMasters). With adding this, I removed the "--custom" argument.

After struggling to install aubio on macOS Sonoma on Mac Silicon I've tested it and it works fine.

Perhaps you want to have a look on it or even merge into your code?

import pyaudio
import numpy as np
import aubio
import signal
import os
import time
import sys
import math

import argparse

from pythonosc.udp_client import SimpleUDPClient

from typing import List, NamedTuple, Tuple

class ServerInfo(NamedTuple):
    ip: str
    port: int
    mode: str
    address: str

parser = argparse.ArgumentParser()
sp = parser.add_subparsers(dest="command")

beat_parser = sp.add_parser("beat",
                            help="Start beat detection")
beat_parser.add_argument("-s", "--server",
                         help="OSC Server address (multiple can be provided), Mode=PLAIN for plain BPM-Value, Mode=HALF for half of BPM-Value, Mode=GMA3 for GrandMA3 Speedmasters (100%=240BPM)", nargs=4,
                         action="append",
                         metavar=("IP", "PORT", "MODE", "ADDRESS"), required=True)
beat_parser.add_argument("-b", "--bufsize",
                         help="Size of audio buffer for beat detection (default: 512)", default=512,
                         type=int)
beat_parser.add_argument("-v", "--verbose",
                         help="Print BPM on beat", action="store_true")
beat_parser.add_argument("-d", "--device",
                         help="Input device index (use list command to see available devices)",
                         default=None, type=int)

list_parser = sp.add_parser("list",
                            help="Print a list of all available audio devices")
args = parser.parse_args()

class BeatPrinter:
    def __init__(self):
        self.state: int = 0
        self.spinner = "¼▚▞▚"

    def print_bpm(self, bpm: float, dbs: float) -> None:
        print(f"{self.spinner[self.state]}\t{bpm:.1f} BPM\t{dbs:.1f} dB")
        self.state = (self.state + 1) % len(self.spinner)

class BeatDetector:
    def __init__(self, buf_size: int, server_info: List[ServerInfo]):
        self.buf_size: int = buf_size
        self.server_info: List[ServerInfo] = server_info

        # Set up pyaudio and aubio beat detector
        self.audio: pyaudio.PyAudio = pyaudio.PyAudio()
        samplerate: int = 44100

        self.stream: pyaudio.Stream = self.audio.open(
            format=pyaudio.paFloat32,
            channels=1,
            rate=samplerate,
            input=True,
            frames_per_buffer=self.buf_size,
            stream_callback=self._pyaudio_callback,
            input_device_index=args.device
        )

        fft_size: int = self.buf_size * 2  #

        # tempo detection
        self.tempo: aubio.tempo = aubio.tempo("default", fft_size, self.buf_size, samplerate)

        # Set up OSC servers to send beat data to
        self.osc_servers: List[Tuple[SimpleUDPClient, str]] = [(SimpleUDPClient(x.ip, x.port), x.address) for x in
                                                               self.server_info]

        # print info
        self.spinner: BeatPrinter = BeatPrinter()

    # this one is called every time enough audio data (buf_size) has been read by the stream
    def _pyaudio_callback(self, in_data, frame_count, time_info, status):
        # Interpret a buffer as a 1-dimensional array (aubio do not work with raw data)
        audio_data = np.frombuffer(in_data, dtype=np.float32)
        # true if beat present
        beat = self.tempo(audio_data)

        # if beat detected , calculate BPM and send to OSC
        if beat[0]:
            # volume level in db
            dbs = aubio.db_spl(aubio.fvec(audio_data))
            bpm = self.tempo.get_bpm()
            # recalculate half BPM
            bpmh = bpm / 2
            # recalculate BPM for GrandMA3
            bpmg = math.sqrt(bpm / 240) * 100

            if args.verbose:
                self.spinner.print_bpm(bpm, dbs)

            for server, server_info in zip(self.osc_servers, self.server_info):
                mode = server_info.mode
                if mode == "PLAIN":
                    server[0].send_message(server[1], bpm)
                elif mode == "HALF":
                    server[0].send_message(server[1], bpmh)
                elif mode == "GMA3":
                    server[0].send_message(server[1], bpmg)

        return None, pyaudio.paContinue  # Tell pyAudio to continue

    def __del__(self):
        self.stream.close()
        self.audio.terminate()
        print('--- Stopped ---')

# find all devices, print info
def list_devices():
    print("Listing all available input devices:\n")
    audio = pyaudio.PyAudio()
    info = audio.get_host_api_info_by_index(0)
    numberofdevices = info.get('deviceCount')

    for i in range(0, numberofdevices):
        if (audio.get_device_info_by_host_api_device_index(0, i).get('maxInputChannels')) > 0:
            print(f"[{i}] {audio.get_device_info_by_host_api_device_index(0, i).get('name')}")

    print("\nUse the number in the square brackets as device index")

# main
def main():
    if args.command == "list":
        list_devices()
        return

    if args.command == "beat":
        # Pack data from arguments into ServerInfo objects
        server_info: List[ServerInfo] = [ServerInfo(x[0], int(x[1]), x[2], x[3]) for x in args.server]

        bd = BeatDetector(args.bufsize, server_info)

        # capture ctrl+c to stop gracefully process
        def signal_handler(none, frame):
            bd.stream.stop_stream()
            bd.stream.close()
            bd.audio.terminate()
            print(' ===> Ctrl + C')
            sys.exit(0)

        signal.signal(signal.SIGINT, signal_handler)

        # Audio processing happens in separate thread, so put this thread to sleep
        if os.name == 'nt':  # Windows is not able to pause the main thread :(
            while True:
                time.sleep(1)
        else:
            signal.pause()
    else:
        print('Nothing to do. Use -h for help')

# main run
if __name__ == "__main__":
    main()

Again, I tested it on my Mac, and everything works fine, now it is possible to use it like this: WLEDAudioSyncRTBeat.py beat --server 127.0.0.1 8000 GMA3 /gMA3/Page3/Fader211 --server 127.0.0.1 8010 PLAIN /MadMapper/GlobalBPM --server 127.0.0.1 8020 HALF /another/target/withhalftheBPM --verbose

My only problem for now is that I cannot get it running under Windows, because also on my Windows Machine I can't install aubio due to several errors, and I just can't find out why. But I need it as a Windows-exe... Also I haven't dived yet into this whole GitHub-stuff until now...

Could you help me please and somehow send me a compiled version? Or if you want, take the whole code and merge it with yours...

I am sure, many GrandMA3-operators would find this useful this as the GMA3-internal BMP-analysis still is very buggy and not usable at all. Thats why I was looking for a solution and found your project...

Kind regards

zak-45 commented 6 months ago

Ok, let me check and let you know. thanks for your time.

zak-45 commented 6 months ago

Done, but MODE is the last arg.

salopeknet commented 6 months ago

Great, already tested! Thank you very much for your time and effort! And especially for your patience with me... :)

In the meantime I managed to install aubio on my windows machine, the code runs also there. But when I try to make an Windows executable with e.g. python -m PyInstaller --console --onedir TEST.py the result is over 600MB in size, not around 15MB like yours. Mostly because of some large ddls (most of it seemingly generic Intel-stuff, like mkl_core.2.dll, mkl_avx512.2.dll [...] furthermore even avccodec-58.dll and libx256).

Would you share your knowledge/toolchain how to compile a most compact executable for Windows? Is there something I can set in the .spec-file, e.g. which files are mandatory or which to skip?

On the other hand I am thinking about making a fork, because in the meantime I have had some more GrandMA-specific ideas for more parameters (some of them already tested), which would make your code too much specialized and for your purposes unnecessary large.

But that would mean, I should use tons of time in learnig how to "use" GitHub at all... ;)

salopeknet commented 6 months ago

Hello zak-45! In the meantime I learned much about Github and some more about Python. The most problems I've had were during compiling, but all of that were gone as I've learned from your repo how to use the workflow... So I forked/cloned your project to: https://github.com/salopeknet/BPM2OSC I hope you are okay with that? Thank you very much for your patience and time and giving the ideas...

zak-45 commented 6 months ago

Nice, that's the charm of open community.