Files
led-controller/tests/play_varying_click_track.py
Jimmy 822d9d8e01 feat(audio): move beat routing server-side and extend presets
Route beat-triggered manual selects from the controller server, add preset background and beat-counter UI support, and bump led-driver to include the matching pattern/runtime fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 20:08:05 +12:00

172 lines
5.9 KiB
Python

#!/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())