- Zones/presets/sequence strip and Pipfile dev command fix - Optional live reload and beat test audio asset + generator Co-authored-by: Cursor <cursoragent@cursor.com>
222 lines
6.6 KiB
Python
222 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Metronome-style mono click track for testing the audio beat detector.
|
|
|
|
Without ``-o``: streams S16LE PCM to ``aplay`` (stdin) until you press Ctrl+C.
|
|
|
|
With ``-o``: writes a WAV file of fixed length and exits.
|
|
|
|
Examples:
|
|
python3 tools/generate_beat_test_track.py
|
|
python3 tools/generate_beat_test_track.py --bpm 90
|
|
python3 tools/generate_beat_test_track.py -o tests/audio/beat_test_120bpm.wav --duration 30
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import math
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import wave
|
|
from pathlib import Path
|
|
|
|
|
|
def _parse_args() -> argparse.Namespace:
|
|
p = argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument(
|
|
"-o",
|
|
"--output",
|
|
type=Path,
|
|
default=None,
|
|
help="If set, write this WAV file and exit (no live playback)",
|
|
)
|
|
p.add_argument("--bpm", type=float, default=120.0, help="Beats per minute (default: 120)")
|
|
p.add_argument(
|
|
"--duration",
|
|
type=float,
|
|
default=30.0,
|
|
help="With -o only: click section length in seconds after intro (default: 30)",
|
|
)
|
|
p.add_argument(
|
|
"--intro-silence",
|
|
type=float,
|
|
default=0.5,
|
|
help="Leading silence in seconds (default: 0.5)",
|
|
)
|
|
p.add_argument("--sample-rate", type=int, default=44100, help="Sample rate Hz (default: 44100)")
|
|
p.add_argument(
|
|
"--click-ms",
|
|
type=float,
|
|
default=18.0,
|
|
help="Approximate click length in ms (default: 18)",
|
|
)
|
|
p.add_argument(
|
|
"--freq",
|
|
type=float,
|
|
default=1000.0,
|
|
help="Click sine frequency Hz (default: 1000)",
|
|
)
|
|
return p.parse_args()
|
|
|
|
|
|
def _click_int16_samples(sr: int, click_ms: float, freq: float) -> tuple[list[int], int]:
|
|
"""One click; returns samples and click_len (same as len(samples))."""
|
|
click_len = max(1, int(sr * max(4.0, click_ms) / 1000.0))
|
|
freq_clamped = max(200.0, min(4000.0, float(freq)))
|
|
floats: list[float] = []
|
|
for i in range(click_len):
|
|
t = i / sr
|
|
env = math.sin(0.5 * math.pi * (i + 1) / click_len) ** 2
|
|
floats.append(env * math.sin(2.0 * math.pi * freq_clamped * t))
|
|
peak = max(abs(x) for x in floats) or 1.0
|
|
scale = 0.92 / peak
|
|
out: list[int] = []
|
|
for x in floats:
|
|
v = int(round(max(-1.0, min(1.0, x * scale)) * 32767.0))
|
|
out.append(max(-32767, min(32767, v)))
|
|
return out, click_len
|
|
|
|
|
|
def _render_scaled_samples(
|
|
sr: int,
|
|
bpm: float,
|
|
intro: float,
|
|
dur: float,
|
|
click_ms: float,
|
|
freq: float,
|
|
) -> tuple[list[float], int, float, int]:
|
|
beat_sec = 60.0 / bpm
|
|
click_len = max(1, int(sr * max(4.0, click_ms) / 1000.0))
|
|
freq_clamped = max(200.0, min(4000.0, float(freq)))
|
|
total_sec = intro + dur
|
|
n_samples = int(sr * total_sec)
|
|
intro_samples = int(sr * intro)
|
|
|
|
samples = [0.0] * n_samples
|
|
beat_samples = int(round(sr * beat_sec))
|
|
if beat_samples < click_len + 1:
|
|
raise SystemExit("BPM too high for this sample rate / click length")
|
|
|
|
beat_idx = 0
|
|
while True:
|
|
start = intro_samples + beat_idx * beat_samples
|
|
if start >= n_samples:
|
|
break
|
|
for i in range(click_len):
|
|
pos = start + i
|
|
if pos >= n_samples:
|
|
break
|
|
t = i / sr
|
|
env = math.sin(0.5 * math.pi * (i + 1) / click_len) ** 2
|
|
s = env * math.sin(2.0 * math.pi * freq_clamped * t)
|
|
samples[pos] += s
|
|
beat_idx += 1
|
|
|
|
peak = max(abs(x) for x in samples) or 1.0
|
|
scale = 0.92 / peak
|
|
for i in range(n_samples):
|
|
samples[i] = max(-1.0, min(1.0, samples[i] * scale))
|
|
|
|
return samples, sr, total_sec, beat_idx
|
|
|
|
|
|
def write_wav_mono16(path: Path, samples: list[float], sr: int) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with wave.open(str(path), "w") as w:
|
|
w.setnchannels(1)
|
|
w.setsampwidth(2)
|
|
w.setframerate(sr)
|
|
for x in samples:
|
|
v = int(round(x * 32767.0))
|
|
w.writeframes(struct.pack("<h", v))
|
|
|
|
|
|
def _stream_aplay_until_interrupt(
|
|
sr: int,
|
|
bpm: float,
|
|
intro_silence: float,
|
|
click_ms: float,
|
|
freq: float,
|
|
) -> None:
|
|
aplay = shutil.which("aplay")
|
|
if not aplay:
|
|
raise SystemExit("aplay not found; install alsa-utils, or use -o to write a WAV file.")
|
|
|
|
click_samps, click_len = _click_int16_samples(sr, click_ms, freq)
|
|
beat_samples = int(round(sr * 60.0 / bpm))
|
|
if beat_samples < click_len + 1:
|
|
raise SystemExit("BPM too high for this sample rate / click length")
|
|
|
|
silence_samples = beat_samples - click_len
|
|
beat_chunk = struct.pack("<" + "h" * len(click_samps), *click_samps) + (
|
|
b"\x00\x00" * silence_samples
|
|
)
|
|
|
|
intro_samples = int(sr * max(0.0, float(intro_silence)))
|
|
intro_chunk = b"\x00\x00" * intro_samples
|
|
|
|
argv = [aplay, "-q", "-t", "raw", "-f", "S16_LE", "-c", "1", "-r", str(sr)]
|
|
proc = subprocess.Popen(argv, stdin=subprocess.PIPE)
|
|
if proc.stdin is None:
|
|
raise SystemExit("aplay did not open stdin")
|
|
|
|
print(
|
|
f"Streaming {bpm} BPM, {sr} Hz mono -> aplay (raw). Ctrl+C to stop.",
|
|
flush=True,
|
|
)
|
|
try:
|
|
if intro_chunk:
|
|
proc.stdin.write(intro_chunk)
|
|
beats_written = 0
|
|
# Write several beats per syscall to reduce overhead
|
|
batch = 8
|
|
multi = beat_chunk * batch
|
|
while True:
|
|
proc.stdin.write(multi)
|
|
beats_written += batch
|
|
if beats_written % 256 == 0:
|
|
proc.stdin.flush()
|
|
except BrokenPipeError:
|
|
print("aplay exited.", flush=True)
|
|
except KeyboardInterrupt:
|
|
print("\nStopped.", flush=True)
|
|
finally:
|
|
try:
|
|
proc.stdin.close()
|
|
except BrokenPipeError:
|
|
pass
|
|
proc.wait(timeout=3)
|
|
|
|
|
|
def main() -> None:
|
|
args = _parse_args()
|
|
sr = max(8000, min(96000, int(args.sample_rate)))
|
|
bpm = max(40.0, min(240.0, float(args.bpm)))
|
|
|
|
if args.output is not None:
|
|
intro = max(0.0, float(args.intro_silence))
|
|
dur = max(1.0, float(args.duration))
|
|
samples, sr_u, total_sec, beats = _render_scaled_samples(
|
|
sr, bpm, intro, dur, float(args.click_ms), float(args.freq)
|
|
)
|
|
write_wav_mono16(args.output, samples, sr_u)
|
|
print(
|
|
f"Wrote {args.output} ({len(samples)} samples, {total_sec:.1f}s, {sr_u} Hz mono): "
|
|
f"{bpm} BPM, ~{beats} beats"
|
|
)
|
|
return
|
|
|
|
_stream_aplay_until_interrupt(
|
|
sr,
|
|
bpm,
|
|
float(args.intro_silence),
|
|
float(args.click_ms),
|
|
float(args.freq),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|