#!/usr/bin/env python3 """Play a click track with tempo variation for BPM detector testing. Examples: python tests/play_varying_click_track.py --start-bpm 90 --end-bpm 150 --seconds 60 python tests/play_varying_click_track.py --pattern steps --step-bpms 100,120,140,160 --step-seconds 8 """ from __future__ import annotations import argparse import math import time import numpy as np import sounddevice as sd def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Play varying-BPM click track") parser.add_argument("--device", default=None, help="Output audio device name or index") parser.add_argument("--sample-rate", type=int, default=44100, help="Output sample rate") parser.add_argument("--seconds", type=float, default=60.0, help="Playback duration") parser.add_argument( "--stabilize-seconds", type=float, default=5.0, help="Play initial BPM for this many seconds before varying", ) parser.add_argument("--pattern", choices=("sweep", "steps"), default="sweep") # Sweep options parser.add_argument("--start-bpm", type=float, default=90.0, help="Sweep start BPM") parser.add_argument("--end-bpm", type=float, default=150.0, help="Sweep end BPM") # Step options parser.add_argument( "--step-bpms", default="100,120,140,160", help="Comma-separated BPMs for step mode", ) parser.add_argument("--step-seconds", type=float, default=8.0, help="Seconds per BPM step") # Click tone options parser.add_argument("--click-ms", type=float, default=25.0, help="Click duration") parser.add_argument("--accent-every", type=int, default=4, help="Accent every N beats") parser.add_argument( "--hold-beats", type=int, default=1, help="Hold BPM for this many beats before recalculating", ) return parser.parse_args() def bpm_for_time(t_s: float, args: argparse.Namespace, step_bpms: list[float]) -> float: if t_s < args.stabilize_seconds: return args.start_bpm if args.pattern == "sweep" else (step_bpms[0] if step_bpms else 120.0) adj_t = t_s - args.stabilize_seconds if args.pattern == "sweep": active_seconds = max(1e-6, args.seconds - args.stabilize_seconds) if active_seconds <= 0: return args.start_bpm alpha = min(1.0, max(0.0, adj_t / active_seconds)) return args.start_bpm + (args.end_bpm - args.start_bpm) * alpha if not step_bpms: return 120.0 if args.step_seconds <= 0: return step_bpms[0] idx = int(adj_t // args.step_seconds) % len(step_bpms) return step_bpms[idx] def main() -> int: args = parse_args() if args.seconds <= 0: raise SystemExit("--seconds must be > 0") if args.sample_rate <= 0: raise SystemExit("--sample-rate must be > 0") step_bpms = [float(x.strip()) for x in args.step_bpms.split(",") if x.strip()] click_samples = max(1, int(args.click_ms * args.sample_rate / 1000.0)) state = { "t": 0.0, # playback time in seconds "next_beat": 0.0, "beat_idx": 0, "current_bpm": 0.0, "held_bpm": 0.0, "hold_counter": 0, "click_remaining": 0, "click_phase": 0, "click_freq": 1320.0, "click_amp": 0.6, } def callback(outdata, frames, _time_info, status): if status: print(f"audio status: {status}") block = np.zeros(frames, dtype=np.float32) for i in range(frames): t = state["t"] dynamic_bpm = bpm_for_time(t, args, step_bpms) state["current_bpm"] = dynamic_bpm bpm = state["held_bpm"] if state["held_bpm"] > 0 else dynamic_bpm beat_interval = 60.0 / max(1e-6, bpm) if t >= state["next_beat"]: hold_beats = max(1, int(args.hold_beats)) if state["hold_counter"] <= 0: state["held_bpm"] = dynamic_bpm state["hold_counter"] = hold_beats state["hold_counter"] -= 1 bpm = state["held_bpm"] beat_interval = 60.0 / max(1e-6, bpm) state["beat_idx"] += 1 state["next_beat"] = t + beat_interval state["click_remaining"] = click_samples state["click_phase"] = 0 accented = ( args.accent_every > 0 and state["beat_idx"] % args.accent_every == 1 ) state["click_freq"] = 1760.0 if accented else 1320.0 state["click_amp"] = 0.9 if accented else 0.6 if state["click_remaining"] > 0: p = state["click_phase"] env = math.exp(-8.0 * (p / click_samples)) sample = state["click_amp"] * env * math.sin( 2.0 * math.pi * state["click_freq"] * (p / args.sample_rate) ) block[i] = sample state["click_phase"] += 1 state["click_remaining"] -= 1 state["t"] += 1.0 / args.sample_rate outdata[:, 0] = block print( f"Playing varying click track for {args.seconds:.1f}s ({args.pattern}), " f"stabilize={args.stabilize_seconds:.1f}s" ) with sd.OutputStream( samplerate=args.sample_rate, channels=1, dtype="float32", callback=callback, device=args.device, blocksize=0, ): start = time.time() last_printed_beat = 0 while (time.time() - start) < args.seconds: beat_idx = int(state["beat_idx"]) if beat_idx != last_printed_beat: last_printed_beat = beat_idx print( f"beat={beat_idx:04d} bpm={state['held_bpm']:.2f} " f"(target={state['current_bpm']:.2f})" ) time.sleep(0.05) print("Done.") return 0 if __name__ == "__main__": raise SystemExit(main())