No internet connection
  1. Home
  2. How to

DIY Metronome app using Javascript

By Andrew Sherman @Andrew_Sherman
    2022-01-12 13:14:41.461Z

    There are various metronome apps available on the app store but I'm wanting something that's ultra-minimalist. It got me thinking that I could make one using javascript / SoundFlow. I'd use it to practice an instrument while waiting for a video to render or while watching something else in the background.

    What I'm aiming for:

    • Press trigger
    • Type BPM value into pop-up dialog (eg "95" and enter / click "start")
    • Metronome app starts playing at pre-determined volume
    • No GUI needed (background process?)
    • When the trigger is pressed again, the app quits and the metronome stops.

    I know how to do most of it via scripting, the thing that I'm unclear on is how to play a sound file again and again at a consistent tempo.

    Does anyone have any ideas?

    • 7 replies
    1. A
      Andrew Sherman @Andrew_Sherman
        2022-01-13 14:39:26.224Z

        Are there any ideas for this?

        I was looking for documentation on the internal SoundFlow audio engine but I couldn't find anything.

        I tried this code but it's not working so far:

        var clickSound = "path/to/click.wav";
        
        const music = new Audio(clickSound);
        music.play();
        music.loop =true;
        music.playbackRate = 2;
        music.pause();
        
        1. You'd need something like this:

          var clickSound = "path/to/click.wav";
          
          sf.system.exec({ commandLine: `afplay "${clickPath}"` });
          
          1. That being said, this wouldn't be very time accurate. It would be very likely to drift. SF's Javascript engine is not very well suited for realtime / time sensitive programming like this.
            At least not right now.

            1. AAndrew Sherman @Andrew_Sherman
                2022-01-13 21:15:56.692Z

                Perfect, thanks Christian. I'll play with that and see what I can come up with.

                1. In reply tochrscheuer:
                  AAndrew Sherman @Andrew_Sherman
                    2025-01-15 09:15:40.387Z

                    Hi Christian, I have had limited success with this metronome script, and I'm not getting anywhere with AI. However I do have a working metronome, it's just the timing of it is nowhere near correct (way too slow and seems to be slightly irregular).

                    I found a reference webpage that uses a kind of look-ahead function to improve the timing, but I don't think I've incorporated it fully/correctly.

                    Could you have a look and give me any pointers?

                    // Set up metronome variables
                    const bpm = 120;
                    const intervalSecs = 60 / bpm;
                    const soundFile = "/System/Library/Sounds/Tink.aiff";
                    
                    // Timing settings
                    const scheduleAheadTime = 0.1;    // How far ahead to schedule audio (seconds)
                    const scheduleInterval = 0.025;    // How frequently to call scheduling function (seconds)
                    
                    // Check global state for metronome status
                    if (!globalState.metronomeOn) {
                        // Start metronome
                        globalState.metronomeOn = true;
                        
                        // Calculate initial timing
                        const startTime = Date.now() / 1000; // Convert to seconds
                        let nextNoteTime = startTime;
                        let currentNote = 0;
                    
                        sf.engine.runInBackground(() => {
                            sf.system.exec({
                                commandLine: `while true; do 
                                    afplay ${soundFile} && sleep ${intervalSecs}
                                done`,
                                executionMode: 'Background',
                            });
                        });
                    
                        log("Metronome started at " + bpm + " BPM");
                        log("Beat interval: " + intervalSecs + " seconds");
                        log("Schedule ahead time: " + scheduleAheadTime + " seconds");
                        log("Scheduler interval: " + scheduleInterval + " seconds");
                    
                    } else {
                        // Stop metronome
                        sf.system.exec({
                            commandLine: "killall bash",
                            executionMode: 'Background',
                        });
                        globalState.metronomeOn = false;
                        log("Metronome stopped");
                    }
                    
                    1. Hi Andrew,

                      It's not likely to be possible to write something that's time-accurate and not drifting, as SoundFlow is not optimized for this - see my comment here:

                      Moreover, in your current script, you're relying in opening new processes to play the sounds, which adds significant latency.

                      I'd advise trying to solve this in a standalone Python script instead - you could then launch that from SoundFlow.

                      1. Christian Scheuer @chrscheuer2025-01-15 21:21:00.548Z2025-01-15 23:25:44.030Z

                        Here's an example Python script:

                        # /// script
                        # dependencies = [
                        #   "simpleaudio",
                        # ]
                        # ///
                        
                        import argparse
                        import time
                        import simpleaudio as sa
                        from threading import Thread
                        
                        # Function to validate BPM input
                        def validate_bpm(value):
                            try:
                                bpm = int(value)
                                if bpm <= 0:
                                    raise ValueError("BPM must be a positive integer.")
                                return bpm
                            except ValueError:
                                raise argparse.ArgumentTypeError("BPM must be a positive integer.")
                        
                        # Function to play a sound (runs in a separate thread for better timing)
                        def play_tick():
                            try:
                                wave_obj = sa.WaveObject.from_wave_file('tick.wav')  # Replace with a valid .wav file
                                wave_obj.play()
                            except FileNotFoundError:
                                print("Error: 'tick.wav' file not found. Please ensure the file is in the same directory as the script.")
                        
                        # Main metronome function
                        def metronome(bpm):
                            interval = 60.0 / bpm
                            print(f"Metronome started at {bpm} BPM. Press Ctrl+C to stop.")
                            try:
                                while True:
                                    sound_thread = Thread(target=play_tick)
                                    sound_thread.start()
                                    time.sleep(interval)
                            except KeyboardInterrupt:
                                print("\nMetronome stopped.")
                        
                        if __name__ == "__main__":
                            parser = argparse.ArgumentParser(description="A simple metronome.")
                            parser.add_argument("bpm", type=validate_bpm, help="Beats per minute (BPM). Must be a positive integer.")
                            args = parser.parse_args()
                        
                            metronome(args.bpm)
                        

                        Save this as metronome.py in your "Documents" folder.
                        Then, add a tick.wav file in the same directory.

                        Then, install uv which is a great python version manager:
                        https://docs.astral.sh/uv/getting-started/installation/

                        You can then run the script like so from Terminal:

                        uv run ~/Documents/metronome.py 120
                        

                        Note, this is a pretty naïve implementation, that could be optimized in several ways.