rosabeats 0.1.3__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rosabeats/__init__.py +1 -1
- rosabeats/__main__.py +59 -0
- rosabeats/beatrecipe_processor.py +63 -46
- rosabeats/beatswitch.py +29 -13
- rosabeats/downbeat.py +207 -0
- rosabeats/rosabeats.py +575 -543
- rosabeats/rosabeats_shell.py +391 -284
- rosabeats/segment_song.py +100 -31
- {rosabeats-0.1.3.dist-info → rosabeats-0.2.0.dist-info}/METADATA +8 -30
- rosabeats-0.2.0.dist-info/RECORD +21 -0
- rosabeats-0.2.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/conftest.py +131 -0
- tests/test_beatrecipe_processor.py +193 -0
- tests/test_downbeat.py +149 -0
- tests/test_rosabeats.py +234 -0
- tests/test_segment_song.py +120 -0
- tests/test_shell.py +305 -0
- docs/beatrecipe_docs.txt +0 -80
- rosabeats-0.1.3.dist-info/RECORD +0 -16
- rosabeats-0.1.3.dist-info/top_level.txt +0 -3
- scripts/reverse_beats_in_bars_rosa.py +0 -48
- scripts/shuffle_bars_rosa.py +0 -35
- scripts/shuffle_beats_rosa.py +0 -29
- {rosabeats-0.1.3.dist-info → rosabeats-0.2.0.dist-info}/WHEEL +0 -0
- {rosabeats-0.1.3.dist-info → rosabeats-0.2.0.dist-info}/entry_points.txt +0 -0
- {rosabeats-0.1.3.dist-info → rosabeats-0.2.0.dist-info}/licenses/LICENSE.md +0 -0
rosabeats/__init__.py
CHANGED
rosabeats/__main__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""
|
|
3
|
+
Entry point for running rosabeats as a module.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python -m rosabeats [command]
|
|
7
|
+
|
|
8
|
+
Commands:
|
|
9
|
+
shell Launch interactive shell (default)
|
|
10
|
+
segment Segment audio file and track beats
|
|
11
|
+
beatswitch Generate beat recipe with alternating patterns
|
|
12
|
+
recipe Process beat recipe files
|
|
13
|
+
|
|
14
|
+
Examples:
|
|
15
|
+
python -m rosabeats # Launch interactive shell
|
|
16
|
+
python -m rosabeats shell # Same as above
|
|
17
|
+
python -m rosabeats segment song.wav # Segment audio file
|
|
18
|
+
python -m rosabeats beatswitch song.wav # Generate beat recipe
|
|
19
|
+
python -m rosabeats recipe file.br # Process beat recipe
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main():
|
|
26
|
+
"""Main entry point for python -m rosabeats."""
|
|
27
|
+
if len(sys.argv) < 2:
|
|
28
|
+
# Default to shell
|
|
29
|
+
from rosabeats.rosabeats_shell import main as shell_main
|
|
30
|
+
sys.exit(shell_main())
|
|
31
|
+
|
|
32
|
+
command = sys.argv[1]
|
|
33
|
+
|
|
34
|
+
# Remove the command from argv so subcommands see correct args
|
|
35
|
+
sys.argv = [f"rosabeats {command}"] + sys.argv[2:]
|
|
36
|
+
|
|
37
|
+
if command in ("shell", "sh"):
|
|
38
|
+
from rosabeats.rosabeats_shell import main as shell_main
|
|
39
|
+
sys.exit(shell_main())
|
|
40
|
+
elif command in ("segment", "seg"):
|
|
41
|
+
from rosabeats.segment_song import main as segment_main
|
|
42
|
+
sys.exit(segment_main() or 0)
|
|
43
|
+
elif command in ("beatswitch", "bs"):
|
|
44
|
+
from rosabeats.beatswitch import main as beatswitch_main
|
|
45
|
+
sys.exit(beatswitch_main() or 0)
|
|
46
|
+
elif command in ("recipe", "br"):
|
|
47
|
+
from rosabeats.beatrecipe_processor import main as recipe_main
|
|
48
|
+
sys.exit(recipe_main() or 0)
|
|
49
|
+
elif command in ("-h", "--help", "help"):
|
|
50
|
+
print(__doc__)
|
|
51
|
+
sys.exit(0)
|
|
52
|
+
else:
|
|
53
|
+
print(f"Unknown command: {command}")
|
|
54
|
+
print("Use 'python -m rosabeats --help' for usage information.")
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
main()
|
|
@@ -4,6 +4,7 @@ import re, sys, os.path, random, logging
|
|
|
4
4
|
|
|
5
5
|
from rosabeats import rosabeats
|
|
6
6
|
|
|
7
|
+
|
|
7
8
|
class beatrecipe_processor(rosabeats):
|
|
8
9
|
@staticmethod
|
|
9
10
|
def ilist(indices):
|
|
@@ -65,6 +66,13 @@ class beatrecipe_processor(rosabeats):
|
|
|
65
66
|
def parse_error(self, error_info):
|
|
66
67
|
self.log.error("%s" % error_info)
|
|
67
68
|
|
|
69
|
+
def parse_command(self, cmd):
|
|
70
|
+
"""Split a command line into (verb, args). args is a list of remaining tokens."""
|
|
71
|
+
parts = cmd.split()
|
|
72
|
+
if not parts:
|
|
73
|
+
return None, []
|
|
74
|
+
return parts[0], parts[1:]
|
|
75
|
+
|
|
68
76
|
def read_beatrecipe(self):
|
|
69
77
|
self.recipe_lines = list()
|
|
70
78
|
with open(self.recipe, "r") as f:
|
|
@@ -75,11 +83,18 @@ class beatrecipe_processor(rosabeats):
|
|
|
75
83
|
|
|
76
84
|
for cmd in lines:
|
|
77
85
|
self.recipe_lines.append(cmd)
|
|
86
|
+
# Load macro definitions at read time so macros are available after init
|
|
87
|
+
if cmd.strip().startswith("def "):
|
|
88
|
+
try:
|
|
89
|
+
_, rest = cmd.split(maxsplit=1)
|
|
90
|
+
name, value = rest.split(maxsplit=1)
|
|
91
|
+
self.define_macro(name, value)
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
78
94
|
|
|
79
95
|
def define_macro(self, name, value):
|
|
80
96
|
if self.macros.get(name) is not None:
|
|
81
|
-
self.
|
|
82
|
-
return False
|
|
97
|
+
self.log.debug("macro %s redefined" % name)
|
|
83
98
|
self.macros[name] = value
|
|
84
99
|
return True
|
|
85
100
|
|
|
@@ -114,7 +129,7 @@ class beatrecipe_processor(rosabeats):
|
|
|
114
129
|
return None
|
|
115
130
|
|
|
116
131
|
# skip lines that start with hash
|
|
117
|
-
if line.startswith(
|
|
132
|
+
if line.startswith("#"):
|
|
118
133
|
return None
|
|
119
134
|
|
|
120
135
|
# remove same line comments but process rest of line
|
|
@@ -127,7 +142,7 @@ class beatrecipe_processor(rosabeats):
|
|
|
127
142
|
|
|
128
143
|
# split multiple commands separated by semicolons
|
|
129
144
|
lines = []
|
|
130
|
-
for l in line.split(
|
|
145
|
+
for l in line.split(";"):
|
|
131
146
|
lines.append(l.strip())
|
|
132
147
|
|
|
133
148
|
return lines
|
|
@@ -339,9 +354,7 @@ class beatrecipe_processor(rosabeats):
|
|
|
339
354
|
return True
|
|
340
355
|
else:
|
|
341
356
|
raise e
|
|
342
|
-
print(
|
|
343
|
-
"[*] (def seg: %s = %s)" % (name, value)
|
|
344
|
-
)
|
|
357
|
+
print("[*] (def seg: %s = %s)" % (name, value))
|
|
345
358
|
|
|
346
359
|
self.define_macro(name, value)
|
|
347
360
|
print("defined %s => %s" % (name, value))
|
|
@@ -357,7 +370,6 @@ class beatrecipe_processor(rosabeats):
|
|
|
357
370
|
print("%15s %15s" % (name, value))
|
|
358
371
|
|
|
359
372
|
elif self.interactive and verb == "ls":
|
|
360
|
-
|
|
361
373
|
print(", ".join(list(self.macros.keys())))
|
|
362
374
|
|
|
363
375
|
elif self.interactive and verb == "quit":
|
|
@@ -392,12 +404,11 @@ class beatrecipe_processor(rosabeats):
|
|
|
392
404
|
def process_interactive(self):
|
|
393
405
|
KeepProcessing = True
|
|
394
406
|
while KeepProcessing:
|
|
395
|
-
|
|
396
407
|
print("")
|
|
397
408
|
print("enter beatrecipe command => ", end="", flush=True)
|
|
398
409
|
|
|
399
410
|
lines = self.preprocess(input())
|
|
400
|
-
|
|
411
|
+
|
|
401
412
|
for cmd in lines:
|
|
402
413
|
print(cmd)
|
|
403
414
|
KeepProcessing = self.execute_command(cmd)
|
|
@@ -413,8 +424,8 @@ class beatrecipe_processor(rosabeats):
|
|
|
413
424
|
except Exception:
|
|
414
425
|
verb = line
|
|
415
426
|
args = ""
|
|
416
|
-
# self.parse_error("could not split line into verb and args")
|
|
417
|
-
# sys.exit(1)
|
|
427
|
+
# self.parse_error("could not split line into verb and args")
|
|
428
|
+
# sys.exit(1)
|
|
418
429
|
|
|
419
430
|
if verb == "file":
|
|
420
431
|
filename = str(args)
|
|
@@ -433,10 +444,12 @@ class beatrecipe_processor(rosabeats):
|
|
|
433
444
|
# if we know our audio source, load it, analyze it, and init outputs
|
|
434
445
|
if self.sourcefile:
|
|
435
446
|
print("One moment as we track your beats for you, madame...")
|
|
436
|
-
self.track_beats(beatsper=per,
|
|
447
|
+
self.track_beats(beatsper=per, downbeat=first)
|
|
437
448
|
self.init_outputs()
|
|
438
449
|
else:
|
|
439
|
-
self.parse_error(
|
|
450
|
+
self.parse_error(
|
|
451
|
+
"a file directive must come before a beats_bar directive"
|
|
452
|
+
)
|
|
440
453
|
sys.exit(1)
|
|
441
454
|
|
|
442
455
|
elif verb == "def":
|
|
@@ -457,46 +470,49 @@ class beatrecipe_processor(rosabeats):
|
|
|
457
470
|
|
|
458
471
|
|
|
459
472
|
def main(args=None):
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
473
|
+
import argparse
|
|
474
|
+
|
|
475
|
+
parser = argparse.ArgumentParser(
|
|
476
|
+
description='Process beat recipe files (.br) to create audio remixes',
|
|
477
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
478
|
+
epilog='''
|
|
479
|
+
Examples:
|
|
480
|
+
beatrecipe-processor -p recipe.br Play the recipe
|
|
481
|
+
beatrecipe-processor -s recipe.br Save output to WAV
|
|
482
|
+
beatrecipe-processor -p -s recipe.br Play and save
|
|
483
|
+
beatrecipe-processor -b recipe.br Write beats output file
|
|
484
|
+
'''
|
|
485
|
+
)
|
|
486
|
+
parser.add_argument('recipes', nargs='+', metavar='RECIPE',
|
|
487
|
+
help='Beat recipe files (.br) to process')
|
|
488
|
+
parser.add_argument('-p', '--play', action='store_true',
|
|
489
|
+
help='Enable audio playback')
|
|
490
|
+
parser.add_argument('-s', '--save', action='store_true',
|
|
491
|
+
help='Save output to WAV file')
|
|
492
|
+
parser.add_argument('-b', '--beats', action='store_true',
|
|
493
|
+
help='Write beats output file')
|
|
494
|
+
parser.add_argument('-d', '--debug', action='store_true',
|
|
495
|
+
help='Enable debug mode')
|
|
496
|
+
|
|
497
|
+
parsed = parser.parse_args(args)
|
|
498
|
+
|
|
499
|
+
output_play = parsed.play
|
|
500
|
+
output_save = parsed.save
|
|
501
|
+
output_beats = parsed.beats
|
|
502
|
+
loglevel = logging.DEBUG if parsed.debug else logging.INFO
|
|
503
|
+
recipes = parsed.recipes
|
|
489
504
|
|
|
490
505
|
if not (output_play or output_save or output_beats):
|
|
491
|
-
|
|
492
|
-
sys.exit(1)
|
|
506
|
+
parser.error("must specify one or more of --play, --save, or --beats for output")
|
|
493
507
|
|
|
494
508
|
for recipe in recipes:
|
|
495
509
|
print("processing %s..." % recipe, flush=True)
|
|
496
510
|
|
|
497
511
|
stub, ext = os.path.splitext(os.path.basename(recipe))
|
|
498
512
|
|
|
499
|
-
p = beatrecipe_processor(
|
|
513
|
+
p = beatrecipe_processor(
|
|
514
|
+
recipe, interactive=False, loglevel=loglevel, debug=True
|
|
515
|
+
)
|
|
500
516
|
|
|
501
517
|
if output_play:
|
|
502
518
|
p.enable_output_play()
|
|
@@ -519,5 +535,6 @@ def main(args=None):
|
|
|
519
535
|
print("error processing %s: %s" % (recipe, e), flush=True)
|
|
520
536
|
raise e
|
|
521
537
|
|
|
538
|
+
|
|
522
539
|
if __name__ == "__main__":
|
|
523
540
|
main()
|
rosabeats/beatswitch.py
CHANGED
|
@@ -21,16 +21,16 @@ class BeatSwitcher(rosabeats.rosabeats):
|
|
|
21
21
|
Attributes:
|
|
22
22
|
infile (str): Path to input audio file
|
|
23
23
|
outfile (str): Path to output beat recipe file (.br)
|
|
24
|
-
|
|
24
|
+
downbeat (int): Beat index of first downbeat
|
|
25
25
|
fmin (int): Minimum number of forward beats
|
|
26
26
|
fmax (int): Maximum number of forward beats
|
|
27
27
|
bmin (int): Minimum number of backward beats
|
|
28
28
|
bmax (int): Maximum number of backward beats
|
|
29
29
|
"""
|
|
30
|
-
|
|
30
|
+
|
|
31
31
|
def __init__(self, infile, debug=False):
|
|
32
32
|
"""Initialize the BeatSwitcher.
|
|
33
|
-
|
|
33
|
+
|
|
34
34
|
Args:
|
|
35
35
|
infile (str): Path to input audio file
|
|
36
36
|
debug (bool, optional): Enable debug mode
|
|
@@ -43,26 +43,26 @@ class BeatSwitcher(rosabeats.rosabeats):
|
|
|
43
43
|
|
|
44
44
|
# Initialize instance variables
|
|
45
45
|
self.outfile = None
|
|
46
|
-
self.
|
|
46
|
+
self._downbeat_arg = None
|
|
47
47
|
self.fmin = None
|
|
48
48
|
self.fmax = None
|
|
49
49
|
self.bmin = None
|
|
50
50
|
self.bmax = None
|
|
51
51
|
|
|
52
|
-
def setup(self, outfile,
|
|
52
|
+
def setup(self, outfile, downbeat=None):
|
|
53
53
|
"""Set up the beat recipe configuration.
|
|
54
|
-
|
|
54
|
+
|
|
55
55
|
Args:
|
|
56
56
|
outfile (str): Path to output beat recipe file (.br)
|
|
57
|
-
|
|
57
|
+
downbeat (int, optional): Beat index of first downbeat (auto-detected if None)
|
|
58
58
|
"""
|
|
59
59
|
# Configure output file
|
|
60
60
|
self.outfile = outfile
|
|
61
61
|
self.enable_output_beats(self.outfile)
|
|
62
62
|
|
|
63
63
|
# Configure beat tracking
|
|
64
|
-
self.
|
|
65
|
-
self.track_beats(
|
|
64
|
+
self._downbeat_arg = downbeat
|
|
65
|
+
self.track_beats(downbeat=downbeat)
|
|
66
66
|
|
|
67
67
|
# Disable WAV output and playback
|
|
68
68
|
self.disable_output_save()
|
|
@@ -90,7 +90,7 @@ class BeatSwitcher(rosabeats.rosabeats):
|
|
|
90
90
|
self.gen_beat_samples()
|
|
91
91
|
|
|
92
92
|
# Start from first full bar
|
|
93
|
-
curr_beat = self.
|
|
93
|
+
curr_beat = self.downbeat
|
|
94
94
|
song_over = False
|
|
95
95
|
direction = "r" # Start with reverse direction
|
|
96
96
|
|
|
@@ -215,8 +215,12 @@ def parse_args():
|
|
|
215
215
|
help="Maximum number of backward beats"
|
|
216
216
|
)
|
|
217
217
|
parser.add_argument(
|
|
218
|
-
"--
|
|
219
|
-
help="
|
|
218
|
+
"--downbeat", type=int, default=None,
|
|
219
|
+
help="Beat index of first downbeat (default: 0)"
|
|
220
|
+
)
|
|
221
|
+
parser.add_argument(
|
|
222
|
+
"--auto-downbeat", action="store_true",
|
|
223
|
+
help="Auto-detect downbeat using DBN"
|
|
220
224
|
)
|
|
221
225
|
parser.add_argument(
|
|
222
226
|
"--debug", action="store_true",
|
|
@@ -247,7 +251,19 @@ def main():
|
|
|
247
251
|
|
|
248
252
|
# Create and run beat switcher
|
|
249
253
|
bs = BeatSwitcher(args.input_file, debug=args.debug)
|
|
250
|
-
|
|
254
|
+
|
|
255
|
+
# Determine downbeat value
|
|
256
|
+
if args.downbeat is not None:
|
|
257
|
+
downbeat = args.downbeat
|
|
258
|
+
elif args.auto_downbeat:
|
|
259
|
+
# Auto-detect using DBN approach
|
|
260
|
+
bs.track_beats(downbeat=0)
|
|
261
|
+
print("Auto-detecting downbeat using DBN...")
|
|
262
|
+
downbeat = bs.detect_downbeat_dbn(bs.beatsperbar)
|
|
263
|
+
else:
|
|
264
|
+
downbeat = 0
|
|
265
|
+
|
|
266
|
+
bs.setup(output_file, downbeat)
|
|
251
267
|
bs.set_parameters(args.fmin, args.fmax, args.bmin, args.bmax)
|
|
252
268
|
bs.run()
|
|
253
269
|
|
rosabeats/downbeat.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Downbeat detection for beat-tracked audio.
|
|
3
|
+
|
|
4
|
+
This module finds the first downbeat (beat 1 of bar 1) given beat times from
|
|
5
|
+
a beat tracker. It uses spectral features that distinguish downbeats:
|
|
6
|
+
- Low-frequency energy (bass/kick drum often hits on downbeats)
|
|
7
|
+
- Onset strength patterns
|
|
8
|
+
- Spectral contrast
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def compute_beat_features(audio_data, sr, beat_times):
|
|
15
|
+
"""Compute features at each beat position for downbeat detection.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
audio_data: Mono audio time series
|
|
19
|
+
sr: Sample rate
|
|
20
|
+
beat_times: Beat times in seconds
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
dict with feature arrays, each of shape (num_beats,)
|
|
24
|
+
"""
|
|
25
|
+
import librosa
|
|
26
|
+
|
|
27
|
+
n_beats = len(beat_times)
|
|
28
|
+
if n_beats == 0:
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
# Convert beat times to samples
|
|
32
|
+
beat_samples = librosa.time_to_samples(beat_times, sr=sr)
|
|
33
|
+
|
|
34
|
+
# Compute spectrograms
|
|
35
|
+
# Standard spectrogram for onset strength
|
|
36
|
+
hop_length = 512
|
|
37
|
+
S = np.abs(librosa.stft(audio_data, hop_length=hop_length))
|
|
38
|
+
|
|
39
|
+
# Mel spectrogram for low-frequency analysis
|
|
40
|
+
mel_S = librosa.feature.melspectrogram(S=S**2, sr=sr, n_mels=128)
|
|
41
|
+
|
|
42
|
+
# Low frequency energy (roughly 20-200 Hz, first ~10 mel bands)
|
|
43
|
+
low_freq_energy = np.sum(mel_S[:10, :], axis=0)
|
|
44
|
+
|
|
45
|
+
# Mid frequency energy (roughly 200-2000 Hz)
|
|
46
|
+
mid_freq_energy = np.sum(mel_S[10:60, :], axis=0)
|
|
47
|
+
|
|
48
|
+
# Onset strength
|
|
49
|
+
onset_env = librosa.onset.onset_strength(S=librosa.amplitude_to_db(S, ref=np.max), sr=sr)
|
|
50
|
+
|
|
51
|
+
# Spectral flux (change in spectrum)
|
|
52
|
+
spectral_flux = np.sqrt(np.sum(np.diff(S, axis=1)**2, axis=0))
|
|
53
|
+
spectral_flux = np.concatenate([[0], spectral_flux])
|
|
54
|
+
|
|
55
|
+
# Convert beat samples to frames
|
|
56
|
+
beat_frames = librosa.samples_to_frames(beat_samples, hop_length=hop_length)
|
|
57
|
+
beat_frames = np.minimum(beat_frames, len(onset_env) - 1)
|
|
58
|
+
|
|
59
|
+
# Extract features at each beat
|
|
60
|
+
features = {
|
|
61
|
+
'onset_strength': onset_env[beat_frames],
|
|
62
|
+
'low_freq_energy': low_freq_energy[beat_frames],
|
|
63
|
+
'mid_freq_energy': mid_freq_energy[beat_frames],
|
|
64
|
+
'spectral_flux': spectral_flux[np.minimum(beat_frames, len(spectral_flux) - 1)],
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Normalize each feature
|
|
68
|
+
for key in features:
|
|
69
|
+
f = features[key]
|
|
70
|
+
if f.max() > f.min():
|
|
71
|
+
features[key] = (f - f.min()) / (f.max() - f.min())
|
|
72
|
+
else:
|
|
73
|
+
features[key] = np.zeros_like(f)
|
|
74
|
+
|
|
75
|
+
# Compute low-to-mid ratio (high on kick drums)
|
|
76
|
+
low = features['low_freq_energy']
|
|
77
|
+
mid = features['mid_freq_energy']
|
|
78
|
+
# Avoid division by zero
|
|
79
|
+
features['low_mid_ratio'] = low / (mid + 0.1)
|
|
80
|
+
if features['low_mid_ratio'].max() > 0:
|
|
81
|
+
features['low_mid_ratio'] /= features['low_mid_ratio'].max()
|
|
82
|
+
|
|
83
|
+
return features
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def score_offset(features, offset, beats_per_bar, weights=None):
|
|
87
|
+
"""Score a particular beat offset for being the downbeat.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
features: Dict of feature arrays from compute_beat_features
|
|
91
|
+
offset: Beat offset to test (0 to beats_per_bar-1)
|
|
92
|
+
beats_per_bar: Number of beats per bar
|
|
93
|
+
weights: Optional dict of feature weights
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
float: Score for this offset (higher = more likely downbeat)
|
|
97
|
+
"""
|
|
98
|
+
if weights is None:
|
|
99
|
+
# Default weights emphasizing low frequency (bass drum)
|
|
100
|
+
weights = {
|
|
101
|
+
'onset_strength': 1.0,
|
|
102
|
+
'low_freq_energy': 2.0,
|
|
103
|
+
'low_mid_ratio': 1.5,
|
|
104
|
+
'spectral_flux': 0.5,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
n_beats = len(features['onset_strength'])
|
|
108
|
+
if n_beats < beats_per_bar:
|
|
109
|
+
return 0.0
|
|
110
|
+
|
|
111
|
+
# Get indices of would-be downbeats with this offset
|
|
112
|
+
downbeat_indices = np.arange(offset, n_beats, beats_per_bar)
|
|
113
|
+
|
|
114
|
+
if len(downbeat_indices) == 0:
|
|
115
|
+
return 0.0
|
|
116
|
+
|
|
117
|
+
# Weight earlier downbeats more (they're more reliable, less affected by
|
|
118
|
+
# song structure changes like drops/breakdowns)
|
|
119
|
+
position_weights = np.exp(-0.02 * np.arange(len(downbeat_indices)))
|
|
120
|
+
|
|
121
|
+
# Compute weighted score for each feature
|
|
122
|
+
total_score = 0.0
|
|
123
|
+
for feature_name, feature_weight in weights.items():
|
|
124
|
+
if feature_name not in features:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
feature_values = features[feature_name][downbeat_indices]
|
|
128
|
+
|
|
129
|
+
# Also compute contrast with non-downbeat positions
|
|
130
|
+
non_downbeat_mask = np.ones(n_beats, dtype=bool)
|
|
131
|
+
non_downbeat_mask[downbeat_indices] = False
|
|
132
|
+
|
|
133
|
+
if non_downbeat_mask.any():
|
|
134
|
+
non_downbeat_mean = features[feature_name][non_downbeat_mask].mean()
|
|
135
|
+
else:
|
|
136
|
+
non_downbeat_mean = 0
|
|
137
|
+
|
|
138
|
+
# Score = weighted sum of (downbeat values - non-downbeat mean)
|
|
139
|
+
contrast = feature_values - non_downbeat_mean
|
|
140
|
+
feature_score = np.sum(contrast * position_weights) / position_weights.sum()
|
|
141
|
+
|
|
142
|
+
total_score += feature_weight * feature_score
|
|
143
|
+
|
|
144
|
+
return total_score
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def detect_downbeat(audio_data, sr, beat_times, beats_per_bar=4, debug=False):
|
|
148
|
+
"""Detect the first downbeat given audio and beat times.
|
|
149
|
+
|
|
150
|
+
This finds which beat offset (0 to beats_per_bar-1) best aligns with
|
|
151
|
+
musical downbeats based on spectral features.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
audio_data: Mono audio time series
|
|
155
|
+
sr: Sample rate
|
|
156
|
+
beat_times: Beat times in seconds from beat tracker
|
|
157
|
+
beats_per_bar: Number of beats per bar
|
|
158
|
+
debug: Print debug information
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
int: Index of the first downbeat in beat_times array
|
|
162
|
+
"""
|
|
163
|
+
n_beats = len(beat_times)
|
|
164
|
+
|
|
165
|
+
if n_beats < beats_per_bar:
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
# Compute features at each beat
|
|
169
|
+
features = compute_beat_features(audio_data, sr, beat_times)
|
|
170
|
+
|
|
171
|
+
if not features:
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
# Score each possible offset
|
|
175
|
+
best_offset = 0
|
|
176
|
+
best_score = -np.inf
|
|
177
|
+
|
|
178
|
+
for offset in range(beats_per_bar):
|
|
179
|
+
score = score_offset(features, offset, beats_per_bar)
|
|
180
|
+
|
|
181
|
+
if debug:
|
|
182
|
+
print(f" Offset {offset}: score = {score:.4f}")
|
|
183
|
+
|
|
184
|
+
if score > best_score:
|
|
185
|
+
best_score = score
|
|
186
|
+
best_offset = offset
|
|
187
|
+
|
|
188
|
+
if debug:
|
|
189
|
+
print(f" Best offset: {best_offset} (score = {best_score:.4f})")
|
|
190
|
+
|
|
191
|
+
return best_offset
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# Alias for compatibility
|
|
195
|
+
def detect_downbeat_dbn(audio_data, sr, beat_times, beats_per_bar=4):
|
|
196
|
+
"""Detect the first downbeat (alias for detect_downbeat).
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
audio_data: Mono audio time series
|
|
200
|
+
sr: Sample rate
|
|
201
|
+
beat_times: Beat times in seconds
|
|
202
|
+
beats_per_bar: Number of beats per bar
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
int: Index of the first downbeat
|
|
206
|
+
"""
|
|
207
|
+
return detect_downbeat(audio_data, sr, beat_times, beats_per_bar)
|