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 CHANGED
@@ -13,7 +13,7 @@ Main features:
13
13
  - Beat Recipe Files: Save and load beat patterns as reusable recipes
14
14
  """
15
15
 
16
- __version__ = "0.1.3"
16
+ __version__ = "0.2.0"
17
17
 
18
18
  # Import main classes and functions from the module
19
19
  from .rosabeats import rosabeats
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.parse_error("macro %s already defined" % name)
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, firstfull=first)
447
+ self.track_beats(beatsper=per, downbeat=first)
437
448
  self.init_outputs()
438
449
  else:
439
- self.parse_error("a file directive must come before a beats_bar directive")
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
- output_play = False
461
- output_save = False
462
- output_beats = False
463
- loglevel = logging.INFO
464
- recipes = []
465
-
466
- # If no args provided, use sys.argv (skipping the script name at index 0)
467
- if args is None:
468
- args = sys.argv[1:]
469
-
470
- for arg in args:
471
- if arg == "-p" or arg == "--play":
472
- output_play = True
473
-
474
- elif arg == "-s" or arg == "--save":
475
- output_save = True
476
-
477
- elif arg == "-b" or arg == "--beats":
478
- output_beats = True
479
-
480
- elif arg == "-d" or arg == "--debug":
481
- loglevel = logging.DEBUG
482
-
483
- else:
484
- recipes.append(arg)
485
-
486
- if len(recipes) < 1:
487
- print("no recipes to process were found on command line")
488
- sys.exit(1)
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
- print("must specify one or more of --play, --save, or --beats for output")
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(recipe, interactive=False, loglevel=loglevel, debug=True)
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
- firstfull (int): Index of first full bar
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.firstfull = None
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, firstfull):
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
- firstfull (int): Index of first full bar
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.firstfull = firstfull
65
- self.track_beats(firstfull=firstfull)
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.firstfullbar
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
- "--firstfull", type=int, default=0,
219
- help="First full bar index"
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
- bs.setup(output_file, args.firstfull)
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)