DIY Metronome app using Javascript
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?
Linked from:
- AAndrew Sherman @Andrew_Sherman
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();
Christian Scheuer @chrscheuer2022-01-13 20:10:26.640Z
You'd need something like this:
var clickSound = "path/to/click.wav"; sf.system.exec({ commandLine: `afplay "${clickPath}"` });
Christian Scheuer @chrscheuer2022-01-13 20:31:44.916Z
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.- AAndrew Sherman @Andrew_Sherman
Perfect, thanks Christian. I'll play with that and see what I can come up with.
- In reply tochrscheuer⬆:AAndrew Sherman @Andrew_Sherman
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"); }
Christian Scheuer @chrscheuer2025-01-15 21:14:02.114Z
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.
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 atick.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.