You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
147 lines
5.2 KiB
147 lines
5.2 KiB
#!/usr/bin/env python3
|
|
"""
|
|
beat_detect.py — librosa-based beat detection for beat-sync-editor skill
|
|
|
|
Usage:
|
|
python beat_detect.py <audio_file> [options]
|
|
|
|
Options:
|
|
--mode Detection mode: "beat" (default) | "onset" | "segment"
|
|
--every-n Only emit every N-th beat (default: 1)
|
|
--manual Comma-separated additional timestamps in seconds to merge in
|
|
--min-gap Minimum gap between beats in seconds (default: 0.25)
|
|
--out Output format: "json" (default) | "text"
|
|
|
|
Output (JSON):
|
|
{
|
|
"bpm": 128.0,
|
|
"total_duration": 210.5,
|
|
"beat_count": 512,
|
|
"beat_times": [0.46, 0.93, 1.40, ...], # seconds
|
|
"sections": [ # only in "segment" mode
|
|
{"label": "intro", "start": 0.0, "end": 32.0, "beats": [...]},
|
|
{"label": "chorus", "start": 32.0, "end": 96.0, "beats": [...]}
|
|
]
|
|
}
|
|
"""
|
|
|
|
import sys
|
|
import json
|
|
import argparse
|
|
|
|
|
|
def detect_beats(audio_path, mode="beat", every_n=1,
|
|
manual_times=None, min_gap=0.25):
|
|
try:
|
|
import librosa
|
|
import numpy as np
|
|
except ImportError:
|
|
print("ERROR: librosa not installed. Run: pip install librosa", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
y, sr = librosa.load(audio_path, mono=True)
|
|
total_duration = librosa.get_duration(y=y, sr=sr)
|
|
|
|
if mode == "onset":
|
|
# Onset detection — more sensitive to transients, great for percussion
|
|
onset_frames = librosa.onset.onset_detect(y=y, sr=sr, units="frames")
|
|
beat_times = librosa.frames_to_time(onset_frames, sr=sr).tolist()
|
|
tempo = float(librosa.beat.tempo(y=y, sr=sr)[0])
|
|
elif mode == "segment":
|
|
# Structural segmentation via novelty curve + beat tracking
|
|
tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr)
|
|
beat_times = librosa.frames_to_time(beat_frames, sr=sr).tolist()
|
|
tempo = float(tempo)
|
|
# Segment using spectral flux novelty
|
|
try:
|
|
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
|
|
bounds = librosa.segment.agglomerative(chroma, k=8)
|
|
bound_times = librosa.frames_to_time(bounds, sr=sr).tolist()
|
|
except Exception:
|
|
bound_times = [total_duration * 0.15, total_duration * 0.5]
|
|
else:
|
|
# Default: standard beat tracking
|
|
tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr)
|
|
beat_times = librosa.frames_to_time(beat_frames, sr=sr).tolist()
|
|
tempo = float(np.asarray(tempo).flat[0])
|
|
|
|
# Apply every-N filter
|
|
if every_n > 1:
|
|
beat_times = beat_times[::every_n]
|
|
|
|
# Merge manual timestamps
|
|
if manual_times:
|
|
beat_times = sorted(set(beat_times) | set(manual_times))
|
|
|
|
# Enforce minimum gap between beats
|
|
if min_gap > 0:
|
|
filtered = []
|
|
last = -999.0
|
|
for t in sorted(beat_times):
|
|
if t - last >= min_gap:
|
|
filtered.append(round(t, 4))
|
|
last = t
|
|
beat_times = filtered
|
|
|
|
result = {
|
|
"bpm": round(tempo, 2),
|
|
"total_duration": round(total_duration, 4),
|
|
"beat_count": len(beat_times),
|
|
"beat_times": [round(t, 4) for t in beat_times],
|
|
}
|
|
|
|
if mode == "segment":
|
|
sections = []
|
|
boundaries = [0.0] + bound_times + [total_duration]
|
|
labels = ["intro"] + [f"section_{i}" for i in range(1, len(boundaries) - 2)] + ["outro"]
|
|
for i in range(len(boundaries) - 1):
|
|
s, e = boundaries[i], boundaries[i + 1]
|
|
section_beats = [t for t in beat_times if s <= t < e]
|
|
sections.append({
|
|
"label": labels[i] if i < len(labels) else f"section_{i}",
|
|
"start": round(s, 4),
|
|
"end": round(e, 4),
|
|
"beats": section_beats
|
|
})
|
|
result["sections"] = sections
|
|
|
|
return result
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Beat detection for beat-sync-editor")
|
|
parser.add_argument("audio_file", help="Path to audio file")
|
|
parser.add_argument("--mode", choices=["beat", "onset", "segment"], default="beat")
|
|
parser.add_argument("--every-n", type=int, default=1, dest="every_n")
|
|
parser.add_argument("--manual", type=str, default="", help="Comma-separated extra timestamps")
|
|
parser.add_argument("--min-gap", type=float, default=0.25, dest="min_gap")
|
|
parser.add_argument("--out", choices=["json", "text"], default="json")
|
|
args = parser.parse_args()
|
|
|
|
manual_times = []
|
|
if args.manual:
|
|
try:
|
|
manual_times = [float(x.strip()) for x in args.manual.split(",") if x.strip()]
|
|
except ValueError:
|
|
print("ERROR: --manual must be comma-separated numbers (e.g. '8.5,32.0')", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
result = detect_beats(
|
|
audio_path=args.audio_file,
|
|
mode=args.mode,
|
|
every_n=args.every_n,
|
|
manual_times=manual_times,
|
|
min_gap=args.min_gap,
|
|
)
|
|
|
|
if args.out == "text":
|
|
print(f"BPM: {result['bpm']}")
|
|
print(f"Duration: {result['total_duration']}s")
|
|
print(f"Beat count: {result['beat_count']}")
|
|
print("Beat times (s):", " ".join(str(t) for t in result["beat_times"]))
|
|
else:
|
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
|