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