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/rosabeats.py CHANGED
@@ -1,20 +1,25 @@
1
1
  #!/usr/bin/env python
2
+ """Core rosabeats module for audio beat tracking, segmentation, and remixing."""
3
+
4
+ from __future__ import annotations
2
5
 
3
- import re
4
- import sys
5
6
  import os.path
6
7
  import random
7
- import time
8
- import joblib
8
+ from typing import Any, Optional
9
9
 
10
- # Optional imports for vamp and ffms2
11
- try:
12
- import vamp
13
- VAMP_AVAILABLE = True
14
- except ImportError:
15
- VAMP_AVAILABLE = False
16
- vamp = None
10
+ import joblib
11
+ import librosa
12
+ import numpy as np
13
+ import scipy
14
+ import scipy.ndimage
15
+ import scipy.sparse.csgraph
16
+ import sklearn
17
+ import sklearn.cluster
18
+ import sklearn.metrics
19
+ import sounddevice as sd
20
+ import soundfile as sf
17
21
 
22
+ # Optional import for ffms2
18
23
  try:
19
24
  import ffms2
20
25
  FFMS2_AVAILABLE = True
@@ -22,23 +27,17 @@ except ImportError:
22
27
  FFMS2_AVAILABLE = False
23
28
  ffms2 = None
24
29
 
25
- import numpy as np
26
- import scipy
27
- import sklearn
28
- import librosa
29
- import soundfile as sf
30
- import sounddevice as sd
31
30
 
32
31
  class rosabeats:
33
32
  """A class for analyzing and manipulating audio files, particularly focused on beat tracking and segmentation.
34
-
33
+
35
34
  This class provides functionality for:
36
35
  - Loading and processing audio files
37
36
  - Beat tracking and tempo analysis
38
37
  - Audio segmentation
39
38
  - Playback and remixing capabilities
40
39
  - Beat and bar manipulation
41
-
40
+
42
41
  Attributes:
43
42
  debug (bool): Class-level debug flag for controlling debug output
44
43
  ffms_source: FFMS2 audio source object
@@ -55,7 +54,7 @@ class rosabeats:
55
54
  total_segments: Total number of segments
56
55
  segments: List of segment information
57
56
  beatsperbar: Number of beats per bar
58
- firstfullbar: Index of first full bar
57
+ downbeat: Beat index of first downbeat (start of first full bar)
59
58
  pulse_device: PulseAudio device index
60
59
  stream: Audio output stream
61
60
  remix: Remix buffer
@@ -69,13 +68,17 @@ class rosabeats:
69
68
  sourcefile: Path to source audio file
70
69
  saved_features_enabled: Flag for saved features functionality
71
70
  """
72
-
73
- debug = False
71
+
72
+ debug: bool = False
73
+
74
+ # -------------------------------------------------------------------------
75
+ # Class methods
76
+ # -------------------------------------------------------------------------
74
77
 
75
78
  @classmethod
76
- def d_print(cls, *args, **kwargs):
79
+ def d_print(cls, *args: Any, **kwargs: Any) -> None:
77
80
  """Print debug messages if debug mode is enabled.
78
-
81
+
79
82
  Args:
80
83
  *args: Variable length argument list to print
81
84
  **kwargs: Arbitrary keyword arguments passed to print()
@@ -83,173 +86,78 @@ class rosabeats:
83
86
  if cls.debug:
84
87
  print("-> ", "".join(map(str, args)), **kwargs, flush=True)
85
88
 
86
- def __init__(self, infile=None, debug=False):
89
+ # -------------------------------------------------------------------------
90
+ # Initialization
91
+ # -------------------------------------------------------------------------
92
+
93
+ def __init__(self, infile: Optional[str] = None, debug: bool = False) -> None:
87
94
  """Initialize the rosabeats object.
88
-
95
+
89
96
  Args:
90
- infile (str, optional): Path to input audio file
91
- debug (bool, optional): Enable debug mode
97
+ infile: Path to input audio file
98
+ debug: Enable debug mode
92
99
  """
93
100
  rosabeats.debug = debug
94
101
 
95
- self.ffms_source = None
96
- self.data = None
97
- self.sr = None
98
- self.channels = None
99
- self.dtype = None
100
- self.mono = None
101
- self.beat_timings = None
102
- self.tempo = None
103
- self.beat_slices = None
104
- self.total_beats = None
105
- self.bars = None
106
- self.total_segments = None
107
- self.segments = None
108
- self.beatsperbar = None
109
- self.firstfullbar = None
110
- self.pulse_device = None
111
- self.stream = None
112
- self.remix = None
113
- self.remix_index = None
114
- self.remix_output_file = None
115
- self.beats_output_file = None
116
- self.beats_output = None
117
- self.output_play = False
118
- self.output_save = False
119
- self.output_beats = False
120
- self.sourcefile = None
102
+ self.ffms_source: Any = None
103
+ self.data: Optional[np.ndarray] = None
104
+ self.sr: Optional[int] = None
105
+ self.channels: Optional[int] = None
106
+ self.dtype: Any = None
107
+ self.mono: Optional[np.ndarray] = None
108
+ self.beat_timings: Optional[np.ndarray] = None
109
+ self.beat_samples: Optional[np.ndarray] = None
110
+ self.tempo: Optional[float] = None
111
+ self.beat_slices: Optional[list] = None
112
+ self.total_beats: Optional[int] = None
113
+ self.total_bars: Optional[int] = None
114
+ self.bars: Any = None
115
+ self.total_segments: Optional[int] = None
116
+ self.segments: Optional[list] = None
117
+ self.beatsperbar: Optional[int] = None
118
+ self.downbeat: Optional[int] = None
119
+ self.pulse_device: Optional[int] = None
120
+ self.stream: Any = None
121
+ self.remix: Optional[np.ndarray] = None
122
+ self.remix_index: Optional[int] = None
123
+ self.remix_output_file: Optional[str] = None
124
+ self.beats_output_file: Optional[str] = None
125
+ self.beats_output: Any = None
126
+ self.output_play: bool = False
127
+ self.output_save: bool = False
128
+ self.output_beats: bool = False
129
+ self.sourcefile: Optional[str] = None
130
+ self.saved_features: Optional[str] = None
121
131
 
122
132
  # things get confusing when you are experimenting a lot and forgetting
123
133
  # that it's using old features/settings that are pickled away out of sight
124
- self.saved_features_enabled = False
134
+ self.saved_features_enabled: bool = False
125
135
 
126
- if not infile is None:
136
+ if infile is not None:
127
137
  self.setfile(infile)
128
138
 
129
- def beat_starts_bar(self, beatnum):
130
- """Check if a beat number starts a new bar.
131
-
132
- Args:
133
- beatnum (int): Beat number to check
134
-
135
- Returns:
136
- int or None: Bar number if beat starts a bar, None otherwise
137
- """
138
- if (beatnum - self.firstfullbar) % self.beatsperbar == 0:
139
- return (beatnum - self.firstfullbar) / self.beatsperbar
140
- else:
141
- return None
142
-
143
- def bar_containing_beat(self, beatnum):
144
- """Get the bar number and beat position within bar for a given beat number.
145
-
146
- Args:
147
- beatnum (int): Beat number to analyze
148
-
149
- Returns:
150
- tuple: (bar_number, beat_position_in_bar)
151
-
152
- Raises:
153
- Exception: If beat number is out of range
154
- """
155
- if beatnum > self.total_beats - 1 or beatnum < 0:
156
- raise Exception("%d is outside possible range" % beatnum)
157
-
158
- bar = int((beatnum - self.firstfullbar) / self.beatsperbar)
159
-
160
- if bar > self.total_bars - 1 or bar < 0:
161
- raise Exception(
162
- "got %d in bar %d but bar %d shouldn't exist" % (beatnum, bar)
163
- )
164
-
165
- rem = (beatnum - self.firstfullbar) % self.beatsperbar
166
-
167
- # returns the bar and the beat # in the bar
168
- return bar, rem
169
-
170
- def set_remix_output_file(self, wavfile):
171
- """Set the output file for the remix.
172
-
173
- Args:
174
- wavfile (str): Path to output WAV file
175
- """
176
- self.remix_output_file = wavfile
177
-
178
- def disable_output_beats(self):
179
- """Disable beat output functionality."""
180
- self.output_beats = False
181
-
182
- def disable_output_save(self):
183
- """Disable save output functionality."""
184
- self.output_save = False
185
-
186
- def disable_output_play(self):
187
- """Disable playback functionality."""
188
- self.output_play = False
189
-
190
- def enable_output_beats(self, beatsfile):
191
- """Enable beat output functionality and set output file.
192
-
193
- Args:
194
- beatsfile (str): Path to output beats file
195
- """
196
- self.set_beats_output_file(beatsfile)
197
- self.output_beats = True
198
-
199
- def enable_output_save(self, wavfile):
200
- """Enable save output functionality and set output file.
201
-
202
- Args:
203
- wavfile (str): Path to output WAV file
204
- """
205
- self.set_remix_output_file(wavfile)
206
- self.output_save = True
207
-
208
- def enable_output_play(self):
209
- """Enable playback functionality."""
210
- self.output_play = True
211
-
212
- def reset_remix(self):
213
- """Reset the remix buffer to initial state."""
214
- if self.sr is None:
215
- self.load()
216
-
217
- if self.remix is not None:
218
- del self.remix
219
-
220
- # initializes an array that will hold 30 minutes of audio samples
221
- length = 30 * 60 * self.sr
222
- self.remix = np.zeros(shape=(self.channels, length), dtype=self.dtype)
223
- self.remix_index = 0
139
+ # -------------------------------------------------------------------------
140
+ # Property aliases for consistent naming
141
+ # -------------------------------------------------------------------------
224
142
 
225
- def extend_remix(self):
226
- """Extend the remix buffer by adding more space."""
227
- if self.sr is None:
228
- self.load()
143
+ @property
144
+ def beats_per_bar(self) -> Optional[int]:
145
+ """Alias for beatsperbar for consistent naming."""
146
+ return self.beatsperbar
229
147
 
230
- rosabeats.d_print()
231
- rosabeats.d_print("***********extending available space for remixed beats")
232
- rosabeats.d_print("***********len(remix[0]) before: %s" % len(self.remix[0]))
233
- # add another 30 minutes
234
- length = 30 * 60 * self.sr
235
- extended_array = np.concatenate(
236
- (self.remix.T, np.zeros(shape=(length, self.channels), dtype=self.dtype)),
237
- axis=0,
238
- )
239
- self.remix = extended_array.T
240
- rosabeats.d_print("***********len(remix[0]) after: %s" % len(self.remix[0]))
241
- rosabeats.d_print("******done extending available space for remixed beats")
148
+ @beats_per_bar.setter
149
+ def beats_per_bar(self, value: int) -> None:
150
+ self.beatsperbar = value
242
151
 
243
- def save_remix(self):
244
- """Save the remix to the output file."""
245
- yt, index = librosa.effects.trim(self.remix)
246
- sf.write(self.remix_output_file, yt.T, self.sr, "PCM_16")
152
+ # -------------------------------------------------------------------------
153
+ # File and path methods
154
+ # -------------------------------------------------------------------------
247
155
 
248
- def setfile(self, infile):
156
+ def setfile(self, infile: str) -> None:
249
157
  """Set the input audio file and initialize related paths.
250
-
158
+
251
159
  Args:
252
- infile (str): Path to input audio file
160
+ infile: Path to input audio file
253
161
  """
254
162
  self.sourcefile = os.path.abspath(infile)
255
163
  dname = os.path.dirname(self.sourcefile)
@@ -257,42 +165,11 @@ class rosabeats:
257
165
  stem, _ = os.path.splitext(bname)
258
166
  self.saved_features = os.path.join(dname, "." + stem + ".pkl")
259
167
 
260
- def find_pulseaudio_device(self):
261
- """Find and set the PulseAudio device for playback."""
262
- dev_count = 0
263
- for dev_name in [x["name"] for x in sd.query_devices()]:
264
- if dev_name == "pulse":
265
- self.pulse_device = dev_count
266
- break
267
- dev_count += 1
268
-
269
- if not self.pulse_device is None:
270
- sd.default.device = self.pulse_device
271
-
272
- def setup_playback(self):
273
- """Set up audio playback configuration."""
274
- if self.sr is None:
275
- self.load()
276
-
277
- sd.default.channels = self.channels
278
- sd.default.samplerate = self.sr
279
- sd.default.dtype = self.dtype
280
-
281
- self.find_pulseaudio_device()
282
-
283
- self.stream = sd.OutputStream()
284
- self.stream.start()
285
-
286
- def init_outputs(self):
287
- """Initialize all enabled output methods."""
288
- if self.output_play:
289
- self.setup_playback()
290
- if self.output_save:
291
- self.reset_remix()
292
- if self.output_beats:
293
- self.start_writing_beats_output()
168
+ # -------------------------------------------------------------------------
169
+ # Audio loading methods
170
+ # -------------------------------------------------------------------------
294
171
 
295
- def load_ffms(self):
172
+ def _load_ffms(self) -> None:
296
173
  """Load audio file using FFMS2 library."""
297
174
  self.ffms_source = ffms2.AudioSource(self.sourcefile)
298
175
  self.ffms_source.init_buffer(count=self.ffms_source.properties.NumSamples)
@@ -301,109 +178,61 @@ class rosabeats:
301
178
  self.channels = self.ffms_source.properties.Channels
302
179
  self.dtype = type(self.data[0][0])
303
180
 
304
- def load_soundfile(self):
181
+ def _load_soundfile(self) -> None:
305
182
  """Load audio file using soundfile library."""
306
183
  self.data, self.sr = sf.read(self.sourcefile, dtype="float32")
307
184
  self.data = self.data.T
308
185
  self.channels = self.data.ndim
309
186
  self.dtype = "float32"
310
187
 
311
- def load_librosa(self):
188
+ def _load_librosa(self) -> None:
312
189
  """Load audio file using librosa library."""
313
190
  self.data, self.sr = librosa.load(self.sourcefile, sr=None, mono=False)
314
191
  self.channels = self.data.ndim
315
192
  self.dtype = type(self.data[0][0])
316
193
 
317
- def load(self):
194
+ def load(self) -> None:
318
195
  """Load audio file using appropriate library based on file extension.
319
-
196
+
320
197
  Raises:
321
198
  ImportError: If FFMS2 is required but not available
322
199
  """
323
200
  base, ext = os.path.splitext(self.sourcefile)
324
201
  if ext == ".wav":
325
202
  rosabeats.d_print("loading via librosa")
326
- self.load_librosa()
203
+ self._load_librosa()
327
204
  elif ext == ".ogg":
328
205
  rosabeats.d_print("loading via soundfile")
329
- self.load_soundfile()
206
+ self._load_soundfile()
330
207
  else:
331
208
  if not FFMS2_AVAILABLE:
332
209
  raise ImportError("ffms2 is required for loading non-wav/ogg files. Please install ffms2.")
333
210
  rosabeats.d_print("loading via ffms")
334
- self.load_ffms()
211
+ self._load_ffms()
335
212
 
336
213
  self.data, _ = librosa.effects.trim(self.data)
337
214
 
338
- def mix_to_mono(self):
215
+ def mix_to_mono(self) -> None:
339
216
  """Convert audio data to mono."""
340
217
  if self.data is None:
341
218
  self.load()
342
219
 
343
220
  self.mono = librosa.to_mono(self.data)
344
221
 
345
- def has_saved_features(self):
346
- """Check if saved features file exists.
347
-
348
- Returns:
349
- bool: True if saved features file exists and is enabled
350
- """
351
- return self.saved_features_enabled and os.path.isfile(self.saved_features)
352
-
353
- def remove_features_file(self):
354
- """Remove the saved features file if it exists."""
355
- if os.path.isfile(self.saved_features):
356
- rosabeats.d_print("removing %s" % self.saved_features)
357
- os.unlink(self.saved_features)
358
- else:
359
- rosabeats.d_print("no features file found")
360
-
361
- def save_features(self):
362
- """Save extracted features to file."""
363
- rosabeats.d_print("saving features...")
364
-
365
- features = dict()
366
- features["tempo"] = self.tempo
367
- features["beatsperbar"] = self.beatsperbar
368
- features["firstfullbar"] = self.firstfullbar
369
- features["total_beats"] = self.total_beats
370
- features["total_bars"] = self.total_bars if self.total_bars else None
371
- features["total_segments"] = self.total_segments
372
- features["beat_timings"] = self.beat_timings
373
- features["beat_samples"] = self.beat_samples
374
- features["beat_slices"] = self.beat_slices
375
- features["segments"] = self.segments
376
- # write features
377
- with open(self.saved_features, "wb") as f:
378
- joblib.dump(features, f)
379
-
380
- def load_saved_features(self):
381
- """Load saved features from file."""
382
- rosabeats.d_print("loading features...")
383
-
384
- with open(self.saved_features, "rb") as f:
385
- features = joblib.load(f)
386
-
387
- self.tempo = features["tempo"]
388
- self.beatsperbar = features["beatsperbar"]
389
- self.firstfullbar = features["firstfullbar"]
390
- self.total_beats = features["total_beats"]
391
- self.total_bars = features["total_bars"]
392
- self.total_segments = features["total_segments"]
393
- self.beat_timings = features["beat_timings"]
394
- self.beat_samples = features["beat_samples"]
395
- self.beat_slices = features["beat_slices"]
396
- self.segments = features["segments"]
222
+ # -------------------------------------------------------------------------
223
+ # Beat tracking methods
224
+ # -------------------------------------------------------------------------
397
225
 
398
- def track_beats(self, beatsper=8, firstfull=0):
226
+ def track_beats(self, beatsper: int = 8, downbeat: int = 0) -> None:
399
227
  """Track beats in the audio file.
400
-
228
+
401
229
  Args:
402
- beatsper (int, optional): Number of beats per bar
403
- firstfull (int, optional): Index of first full bar
230
+ beatsper: Number of beats per bar (default: 8)
231
+ downbeat: Beat index of first downbeat (default: 0).
232
+ Use detect_downbeat() for auto-detection.
404
233
  """
405
- if self.has_saved_features():
406
- self.load_saved_features()
234
+ if self._has_saved_features():
235
+ self._load_saved_features()
407
236
  return
408
237
 
409
238
  if self.mono is None:
@@ -419,104 +248,75 @@ class rosabeats:
419
248
  self.total_beats = len(self.beat_timings)
420
249
 
421
250
  self.beatsperbar = beatsper
422
- self.firstfullbar = firstfull
423
- self.total_bars = int((self.total_beats - self.firstfullbar) / self.beatsperbar)
251
+ self.downbeat = downbeat
424
252
 
425
- self.save_features()
253
+ self.total_bars = int((self.total_beats - self.downbeat) / self.beatsperbar)
426
254
 
427
- def segment(self, method="segmentino", redo=False, max_clusters=None):
428
- """Segment the audio file using the specified method.
429
-
430
- Args:
431
- method (str, optional): Segmentation method to use ("laplacian", "segmentino", or "backtrack"; "segmentino" is default)
432
- (currently, both laplacian and backtrack are broken)
433
- redo (bool, optional): Force re-segmentation even if segments exist
434
-
435
- Raises:
436
- ValueError: If invalid method is specified
437
- ImportError: If method="segmentino" but vamp is not available
438
- ValueError: If max_clusters is not specified for laplacian segmentation
439
- """
440
- if method not in ["laplacian", "segmentino", "backtrack"]:
441
- raise ValueError("method must be either 'laplacian', 'segmentino' or 'backtrack'")
442
-
443
- if method == "segmentino" and not VAMP_AVAILABLE:
444
- raise ImportError("vamp is required for segmentino segmentation. Please install vamp.")
445
-
446
- if max_clusters is None and method == "laplacian":
447
- raise ValueError("max_clusters must be specified for laplacian segmentation")
448
-
449
- if max_clusters is not None and method != "laplacian":
450
- raise ValueError("max_clusters should only be specified for laplacian segmentation")
451
-
452
- if method == "backtrack":
453
- self.segment_backtrack(redo)
454
- elif method == "laplacian":
455
- self.segment_laplacian(redo, max_clusters)
456
- else:
457
- self.segment_segmentino(redo)
255
+ self._save_features()
256
+
257
+ def detect_downbeat(self, beatsper: int) -> int:
258
+ """Detect downbeat using Dynamic Bayesian Network approach.
259
+
260
+ This uses a DBN/HMM approach for downbeat detection,
261
+ implemented in pure Python.
458
262
 
459
- def segment_backtrack(self, redo=False):
460
- """Segment audio using librosa onset detection and backtracking method.
461
-
462
263
  Args:
463
- redo (bool, optional): Force re-segmentation even if segments exist
264
+ beatsper: Number of beats per bar
265
+
266
+ Returns:
267
+ Beat index of the detected first downbeat
464
268
  """
465
- if self.beat_timings is None:
466
- self.track_beats()
269
+ from rosabeats.downbeat import detect_downbeat_dbn
467
270
 
468
- if not self.total_segments is None and redo is False:
469
- rosabeats.d_print(
470
- "warning: you already have segment data and did not specify a redo"
471
- )
472
- return
271
+ if self.mono is None:
272
+ self.mix_to_mono()
273
+
274
+ if self.beat_timings is None:
275
+ raise Exception("must call track_beats first to get beat timings")
473
276
 
474
- # Get onset times
475
- onset_frames = librosa.onset.onset_detect(y=self.mono, sr=self.sr, backtrack=True)
277
+ rosabeats.d_print("detecting downbeat using DBN...")
476
278
 
477
- # Initialize segments list
478
- self.segments = []
479
- count = 0
279
+ downbeat_idx = detect_downbeat_dbn(
280
+ self.mono, self.sr, self.beat_timings, beats_per_bar=beatsper
281
+ )
480
282
 
481
- for frame_s, seg_len in zip(onset_frames, onset_frames[1:]):
482
- segment_boundaries = (frame_s, frame_s + seg_len)
483
- segment_time_boundaries = librosa.samples_to_time(segment_boundaries, sr=self.sr)
484
- start, end = segment_time_boundaries
485
- duration = end - start
283
+ rosabeats.d_print(f"DBN detected downbeat at beat {downbeat_idx}")
284
+ return downbeat_idx
486
285
 
487
- segment = dict()
488
- segment["label"] = "segment" + str(count)
489
- segment["start"] = start
490
- segment["duration"] = duration
491
- segment["samples"] = segment_boundaries
492
- segment["beats"] = []
493
- segment["bars"] = []
286
+ # Backward compatibility alias
287
+ detect_downbeat_dbn = detect_downbeat
494
288
 
495
- self.segments.append(segment)
289
+ # -------------------------------------------------------------------------
290
+ # Segmentation methods
291
+ # -------------------------------------------------------------------------
496
292
 
497
- count += 1
293
+ def segment(self, redo: bool = False, max_clusters: int = 48) -> None:
294
+ """Segment the audio file using Laplacian spectral clustering.
498
295
 
499
- self.total_segments = len(self.segments)
500
- self.save_features()
296
+ Args:
297
+ redo: Force re-segmentation even if segments exist
298
+ max_clusters: Maximum clusters (default: 48)
299
+ """
300
+ self.segment_laplacian(redo, max_clusters)
501
301
 
502
- def segment_laplacian(self, redo=False, max_clusters=48):
302
+ def segment_laplacian(self, redo: bool = False, max_clusters: int = 48) -> None:
503
303
  """Segment audio using Laplacian segmentation method.
504
-
304
+
505
305
  Args:
506
- redo (bool, optional): Force re-segmentation even if segments exist
507
- max_clusters (int, optional): Maximum number of clusters to use
306
+ redo: Force re-segmentation even if segments exist
307
+ max_clusters: Maximum number of clusters to use
508
308
  """
509
309
  if self.beat_timings is None:
510
310
  self.track_beats()
511
311
 
512
- if not self.total_segments is None and redo is False:
312
+ if self.total_segments is not None and redo is False:
513
313
  rosabeats.d_print(
514
314
  "warning: you already have segment data and did not specify a redo"
515
315
  )
516
316
  return
517
317
 
518
318
  rosabeats.d_print("segmenting song...")
519
- duration = librosa.get_duration(y=self.mono,sr=self.sr)
319
+ duration = librosa.get_duration(y=self.mono, sr=self.sr)
520
320
 
521
321
  beat_frames = librosa.time_to_frames(self.beat_timings, sr=self.sr)
522
322
 
@@ -524,11 +324,10 @@ class rosabeats:
524
324
  N_OCTAVES = 7
525
325
 
526
326
  cqt = librosa.cqt(y=self.mono, sr=self.sr, bins_per_octave=BINS_PER_OCTAVE, n_bins=N_OCTAVES * BINS_PER_OCTAVE)
527
- C = librosa.amplitude_to_db( np.abs(cqt), ref=np.max)
327
+ C = librosa.amplitude_to_db(np.abs(cqt), ref=np.max)
528
328
 
529
329
  Csync = librosa.util.sync(C, beat_frames, aggregate=np.median)
530
330
 
531
-
532
331
  beat_times = librosa.frames_to_time(librosa.util.fix_frames(beat_frames,
533
332
  x_min=0,
534
333
  x_max=C.shape[1]),
@@ -605,7 +404,7 @@ class rosabeats:
605
404
 
606
405
  segment_length = 1
607
406
  else:
608
- segment_length +=1
407
+ segment_length += 1
609
408
 
610
409
  ratio = float(segment_count) / float(clusters)
611
410
  min_segment_len = min(segment_lengths)
@@ -641,86 +440,46 @@ class rosabeats:
641
440
 
642
441
  self.segments = []
643
442
  prev = 0
644
- for sample, label in zip(bound_samples,bound_segs):
645
- segment_boundaries = (prev, sample-1)
443
+ for sample, label in zip(bound_samples, bound_segs):
444
+ segment_boundaries = (prev, sample - 1)
646
445
  prev = sample
647
- segment_time_boundaries = librosa.samples_to_time(segment_boundaries,sr=self.sr)
446
+ segment_time_boundaries = librosa.samples_to_time(segment_boundaries, sr=self.sr)
648
447
  start, end = segment_time_boundaries
649
448
  duration = end - start
650
- segment = {}
651
- try:
652
- segment['label'] = seg_labels[int(label)]
653
- except:
654
- segment['label'] = label
655
-
656
- segment['start'] = start
657
- segment['duration'] = duration
658
- segment['samples'] = segment_boundaries
659
- segment['beats'] = []
660
- segment['bars'] = []
449
+ segment = {
450
+ 'label': int(label),
451
+ 'start': start,
452
+ 'duration': duration,
453
+ 'samples': segment_boundaries,
454
+ 'beats': [],
455
+ 'bars': [],
456
+ }
457
+ self.segments.append(segment)
661
458
 
459
+ # Add final segment from last boundary to end of audio
460
+ total_samples = len(self.mono)
461
+ if prev < total_samples:
462
+ final_label = int(seg_ids[-1])
463
+ segment_boundaries = (prev, total_samples - 1)
464
+ segment_time_boundaries = librosa.samples_to_time(segment_boundaries, sr=self.sr)
465
+ start, end = segment_time_boundaries
466
+ duration = end - start
467
+ segment = {
468
+ 'label': final_label,
469
+ 'start': start,
470
+ 'duration': duration,
471
+ 'samples': segment_boundaries,
472
+ 'beats': [],
473
+ 'bars': [],
474
+ }
662
475
  self.segments.append(segment)
663
476
 
664
477
  self.total_segments = len(self.segments)
665
- self.save_features()
666
-
667
- ##TODO## segment_laplacian needs to add any unsegmented part of the song as a last segment
668
- ##TODO## for example, using max clusters of 10 with example audio, we get 10 segments, but ending with beat 254 (there are 308)
669
- ##TODO## even if max clusters is 48, it only gives us segments including up to beat 303
670
-
671
- def segment_segmentino(self, redo=False):
672
- """Segment audio using the Segmentino plugin.
673
-
674
- Args:
675
- redo (bool, optional): Force re-segmentation even if segments exist
676
-
677
- Raises:
678
- RuntimeError: If segmentino plugin fails to return valid data
679
- """
680
- if self.data is None:
681
- self.load()
478
+ self._save_features()
682
479
 
683
- if not self.total_segments is None and redo is False:
684
- rosabeats.d_print(
685
- "warning: you already have segment data and did not specify a redo"
686
- )
687
- return
688
-
689
- rosabeats.d_print("segmenting song...")
690
- try:
691
- segmented = vamp.collect(self.data, self.sr, "segmentino:segmentino")
692
- except Exception as e:
693
- rosabeats.d_print(f"Error loading segmentino plugin: {str(e)}")
694
- raise RuntimeError(f"Failed to run segmentino segmentation: {str(e)}") from e
695
-
696
- if not segmented or "list" not in segmented:
697
- rosabeats.d_print("Segmentino plugin returned invalid data")
698
- raise RuntimeError("Segmentino plugin failed to return valid segment data")
699
-
700
- self.total_segments = len(segmented["list"])
701
- self.segments = self.total_segments * [None]
702
-
703
- for count, result in enumerate(segmented["list"]):
704
- label = result["label"]
705
- start = float(result["timestamp"])
706
- duration = float(result["duration"])
707
- end = start + duration
708
-
709
- self.segments[count] = dict()
710
- self.segments[count]["label"] = label
711
- self.segments[count]["start"] = start
712
- self.segments[count]["duration"] = duration
713
- self.segments[count]["samples"] = librosa.time_to_samples(
714
- (start, end), sr=self.sr
715
- )
716
- self.segments[count]["beats"] = []
717
- self.segments[count]["bars"] = []
718
-
719
- self.save_features()
720
-
721
- def segmentize_beats(self):
480
+ def segmentize_beats(self) -> None:
722
481
  """Associate beats and bars with segments.
723
-
482
+
724
483
  Raises:
725
484
  Exception: If segments or beat timings are not available
726
485
  """
@@ -737,35 +496,26 @@ class rosabeats:
737
496
 
738
497
  # for each beat in the song...
739
498
  for beat_num in range(self.total_beats - 1):
740
- # rosabeats.d_print("examining beat %d" % beat_num)
741
-
742
499
  # obtain sample where beat starts
743
500
  beat_first = self.beat_slices[beat_num][0]
744
- # rosabeats.d_print("beat %d, %d <= %d <= %d ?" % (beat_num, seg_first, beat_first, seg_last))
745
501
 
746
502
  # see if the beat starts inside the segment boundaries
747
503
  if beat_first >= seg_first and beat_first <= seg_last:
748
504
  # the beat starts firmly within the segment
749
505
  # so save this beat to the list of beats associated with this segment
750
506
  seg["beats"].append(beat_num)
751
- # rosabeats.d_print("BEAT %d is in segment %d" % (beat_num, idx))
752
507
 
753
508
  # now let's see if this beat starts a bar
754
509
  bar_num = self.beat_starts_bar(beat_num)
755
510
 
756
511
  # if it does start a bar...
757
- if not bar_num is None:
758
- # rosabeats.d_print("beat %d starts bar %d" % (beat_num, bar_num))
759
-
760
- # determine the beat number of the last beat in the bar (i.e. 0 + (8-1) = 7,k so 0-7)
512
+ if bar_num is not None:
513
+ # determine the beat number of the last beat in the bar (i.e. 0 + (8-1) = 7, so 0-7)
761
514
  beat_num_final = int(beat_num + (self.beatsperbar - 1))
762
- # print("bar %d starts with beat %d and ends with beat %d" % (bar_num, beat_num, beat_num_final))
763
515
 
764
516
  # obtain sample where final beat in bar starts
765
517
  try:
766
518
  beat_final_first = self.beat_slices[beat_num_final][0]
767
- # rosabeats.d_print("beat %d stats on sample %d" % (beat_num_final, beat_final_first))
768
- # rosabeats.d_print("segment starts sample %d and ends sample %d" % (seg_first, seg_last))
769
519
  except:
770
520
  rosabeats.d_print(
771
521
  "warning: beat %d does not exist" % beat_num_final
@@ -775,74 +525,173 @@ class rosabeats:
775
525
  # see if the final beat in bar starts inside the segment boundaries
776
526
  if beat_final_first >= seg_first and beat_final_first <= seg_last:
777
527
  # last beat starts in segment
778
- # rosabeats.d_print(" BAR %d is in segment %d" % (bar_num, idx))
779
528
  seg["bars"].append(int(bar_num))
780
529
 
781
- # alternatively, bar_beat_First = self.beat_slices[beat_num_final][0]
782
- # and then check that that is <= segment, meaning last beat of bar STARTS inside segment
783
- import pprint #TODO# remove
784
- pprint.pprint(self.segments) #TODO# remove
785
-
530
+ self._save_features()
786
531
 
787
- self.save_features()
532
+ # -------------------------------------------------------------------------
533
+ # Bar/beat calculation methods
534
+ # -------------------------------------------------------------------------
788
535
 
789
- def divide_bars(self):
790
- """Deprecated method that no longer performs any action."""
791
- rosabeats.d_print("warning: divide_bars() no longer does anything")
536
+ def beat_starts_bar(self, beatnum: int) -> Optional[int]:
537
+ """Check if a beat number starts a new bar.
792
538
 
793
- def set_beats_output_file(self, beatsfile):
794
- """Set the output file for beat information.
795
-
796
539
  Args:
797
- beatsfile (str): Path to output beats file
540
+ beatnum: Beat number to check
541
+
542
+ Returns:
543
+ Bar number if beat starts a bar, None otherwise
798
544
  """
799
- self.beats_output_file = beatsfile
545
+ if (beatnum - self.downbeat) % self.beatsperbar == 0:
546
+ return (beatnum - self.downbeat) // self.beatsperbar
547
+ else:
548
+ return None
800
549
 
801
- def set_default_beats_output_file(self):
802
- """Set default beats output file based on source filename."""
803
- basename = os.path.basename(self.sourcefile)
804
- stub, ext = os.path.splitext(basename)
805
- self.set_beats_output_file(stub + "_beats.br")
550
+ def bar_containing_beat(self, beatnum: int) -> tuple[int, int]:
551
+ """Get the bar number and beat position within bar for a given beat number.
806
552
 
807
- def start_writing_beats_output(self):
808
- """Initialize beat output file and write header information."""
809
- if self.beats_output_file == None:
810
- self.set_default_beats_output_file()
553
+ Args:
554
+ beatnum: Beat number to analyze
811
555
 
812
- self.beats_output = open(self.beats_output_file, "w")
813
- self.beats_output.write("file %s\n" % self.sourcefile)
814
- self.beats_output.write(
815
- "beats_bar %d %d\n" % (self.beatsperbar, self.firstfullbar)
556
+ Returns:
557
+ Tuple of (bar_number, beat_position_in_bar)
558
+
559
+ Raises:
560
+ Exception: If beat number is out of range
561
+ """
562
+ if beatnum > self.total_beats - 1 or beatnum < 0:
563
+ raise Exception("%d is outside possible range" % beatnum)
564
+
565
+ bar = int((beatnum - self.downbeat) / self.beatsperbar)
566
+
567
+ if bar > self.total_bars - 1 or bar < 0:
568
+ raise Exception(
569
+ "got %d in bar %d but bar %d shouldn't exist" % (beatnum, bar, bar)
570
+ )
571
+
572
+ rem = (beatnum - self.downbeat) % self.beatsperbar
573
+
574
+ # returns the bar and the beat # in the bar
575
+ return bar, rem
576
+
577
+ # -------------------------------------------------------------------------
578
+ # Output configuration methods
579
+ # -------------------------------------------------------------------------
580
+
581
+ def _set_remix_output_file(self, wavfile: str) -> None:
582
+ """Set the output file for the remix.
583
+
584
+ Args:
585
+ wavfile: Path to output WAV file
586
+ """
587
+ self.remix_output_file = wavfile
588
+
589
+ def _set_beats_output_file(self, beatsfile: str) -> None:
590
+ """Set the output file for beat information.
591
+
592
+ Args:
593
+ beatsfile: Path to output beats file
594
+ """
595
+ self.beats_output_file = beatsfile
596
+
597
+ def _set_default_beats_output_file(self) -> None:
598
+ """Set default beats output file based on source filename."""
599
+ basename = os.path.basename(self.sourcefile)
600
+ stub, ext = os.path.splitext(basename)
601
+ self._set_beats_output_file(stub + "_beats.br")
602
+
603
+ def _start_writing_beats_output(self) -> None:
604
+ """Initialize beat output file and write header information."""
605
+ if self.beats_output_file is None:
606
+ self._set_default_beats_output_file()
607
+
608
+ self.beats_output = open(self.beats_output_file, "w")
609
+ self.beats_output.write("file %s\n" % self.sourcefile)
610
+ self.beats_output.write(
611
+ "beats_bar %d %d\n" % (self.beatsperbar, self.downbeat)
816
612
  )
817
613
 
818
- def shutdown(self):
819
- """Clean up and close all output streams."""
614
+ def enable_output_play(self) -> None:
615
+ """Enable playback functionality."""
616
+ self.output_play = True
617
+
618
+ def disable_output_play(self) -> None:
619
+ """Disable playback functionality."""
620
+ self.output_play = False
621
+
622
+ def enable_output_save(self, wavfile: str) -> None:
623
+ """Enable save output functionality and set output file.
624
+
625
+ Args:
626
+ wavfile: Path to output WAV file
627
+ """
628
+ self._set_remix_output_file(wavfile)
629
+ self.output_save = True
630
+
631
+ def disable_output_save(self) -> None:
632
+ """Disable save output functionality."""
633
+ self.output_save = False
634
+
635
+ def enable_output_beats(self, beatsfile: str) -> None:
636
+ """Enable beat output functionality and set output file.
637
+
638
+ Args:
639
+ beatsfile: Path to output beats file
640
+ """
641
+ self._set_beats_output_file(beatsfile)
642
+ self.output_beats = True
643
+
644
+ def disable_output_beats(self) -> None:
645
+ """Disable beat output functionality."""
646
+ self.output_beats = False
647
+
648
+ def init_outputs(self) -> None:
649
+ """Initialize all enabled output methods."""
820
650
  if self.output_play:
821
- self.stream.close()
651
+ self.setup_playback()
822
652
  if self.output_save:
823
- self.save_remix()
653
+ self.reset_remix()
824
654
  if self.output_beats:
825
- self.beats_output.close()
655
+ self._start_writing_beats_output()
826
656
 
827
- def write_out(self, text):
828
- """Write text to beats output file.
829
-
830
- Args:
831
- text (str): Text to write
832
- """
833
- if self.beats_output == None:
834
- self.start_writing_beats_output()
657
+ # -------------------------------------------------------------------------
658
+ # Playback methods
659
+ # -------------------------------------------------------------------------
835
660
 
836
- self.beats_output.write("%s\n" % text)
661
+ def _find_pulseaudio_device(self) -> None:
662
+ """Find and set the PulseAudio device for playback."""
663
+ dev_count = 0
664
+ for dev_name in [x["name"] for x in sd.query_devices()]:
665
+ if dev_name == "pulse":
666
+ self.pulse_device = dev_count
667
+ break
668
+ dev_count += 1
837
669
 
838
- def play_beat(self, b, silent=False, divisor=1):
670
+ if self.pulse_device is not None:
671
+ sd.default.device = self.pulse_device
672
+
673
+ def setup_playback(self) -> None:
674
+ """Set up audio playback configuration."""
675
+ if self.sr is None:
676
+ self.load()
677
+
678
+ sd.default.channels = self.channels
679
+ sd.default.samplerate = self.sr
680
+ sd.default.dtype = self.dtype
681
+
682
+ self._find_pulseaudio_device()
683
+
684
+ self.stream = sd.OutputStream()
685
+ self.stream.start()
686
+
687
+ def play_beat(self, b: int, silent: bool = False, divisor: int = 1) -> None:
839
688
  """Play a single beat.
840
-
689
+
841
690
  Args:
842
- b (int): Beat number to play
843
- silent (bool, optional): Suppress console output
844
- divisor (int, optional): Beat division factor
845
-
691
+ b: Beat number to play
692
+ silent: Suppress console output
693
+ divisor: Beat division factor
694
+
846
695
  Raises:
847
696
  Exception: If beat tracking has not been performed
848
697
  """
@@ -877,28 +726,8 @@ class rosabeats:
877
726
  )
878
727
 
879
728
  if self.output_save:
880
- try:
881
- # try copying the beat data into the existing remix buffer
882
- self.remix[
883
- 0,
884
- self.remix_index : self.remix_index + len(self.data[0][first:last]),
885
- ] += self.data[0][first:last]
886
- self.remix[
887
- 1,
888
- self.remix_index : self.remix_index + len(self.data[1][first:last]),
889
- ] += self.data[1][first:last]
890
- except ValueError:
891
- # if it fails, extend the buffer and try again
892
- self.extend_remix()
893
- self.remix[
894
- 0,
895
- self.remix_index : self.remix_index + len(self.data[0][first:last]),
896
- ] += self.data[0][first:last]
897
- self.remix[
898
- 1,
899
- self.remix_index : self.remix_index + len(self.data[1][first:last]),
900
- ] += self.data[1][first:last]
901
-
729
+ self._copy_to_remix(0, self.data[0][first:last])
730
+ self._copy_to_remix(1, self.data[1][first:last])
902
731
  self.remix_index += len(self.data[0][first:last])
903
732
 
904
733
  if self.output_beats:
@@ -907,31 +736,70 @@ class rosabeats:
907
736
  else:
908
737
  self.write_out("beats %d" % b)
909
738
 
910
- def play_beats(self, beats):
739
+ def play_beats(self, beats: list[int]) -> None:
911
740
  """Play a sequence of beats.
912
-
741
+
913
742
  Args:
914
- beats (list): List of beat numbers to play
743
+ beats: List of beat numbers to play
915
744
  """
916
745
  for beat in beats:
917
746
  self.play_beat(beat)
918
747
  print(flush=True)
919
748
 
920
- def play_bars(self, bars, reverse=False):
749
+ def play_bar(self, m: int, reverse: bool = False, silent: bool = False) -> None:
750
+ """Play a single bar.
751
+
752
+ Args:
753
+ m: Bar number to play
754
+ reverse: Play bar in reverse order
755
+ silent: Suppress console output
756
+
757
+ Raises:
758
+ Exception: If beat tracking has not been performed
759
+ """
760
+ if self.beatsperbar is None or self.beat_slices is None:
761
+ raise Exception("must track beats before you can play bar")
762
+
763
+ if self.output_beats:
764
+ self.write_out("# bar %d" % m)
765
+
766
+ if not silent:
767
+ print("[%d]" % m, end="", flush=True)
768
+
769
+ first_beat = int(m * self.beatsperbar) + self.downbeat
770
+ last_beat = int(first_beat + self.beatsperbar) - 1
771
+ if last_beat > self.total_beats - 1:
772
+ last_beat = int(self.total_beats) - 1
773
+
774
+ beats = [x for x in range(first_beat, last_beat + 1)]
775
+ if reverse:
776
+ if not silent:
777
+ print("[rev] ", end="", flush=True)
778
+ beats.reverse()
779
+
780
+ for beat in beats:
781
+ if beat == first_beat:
782
+ if not silent:
783
+ print("*", end="", flush=True)
784
+ self.play_beat(beat)
785
+ if not silent:
786
+ print(flush=True)
787
+
788
+ def play_bars(self, bars: list[int], reverse: bool = False) -> None:
921
789
  """Play a sequence of bars.
922
-
790
+
923
791
  Args:
924
- bars (list): List of bar numbers to play
925
- reverse (bool, optional): Play bars in reverse order
792
+ bars: List of bar numbers to play
793
+ reverse: Play bars in reverse order
926
794
  """
927
795
  for bar in bars:
928
796
  self.play_bar(bar, reverse=reverse)
929
797
 
930
- def rest(self, beats):
798
+ def rest(self, beats: float) -> None:
931
799
  """Add silence for specified number of beats.
932
-
800
+
933
801
  Args:
934
- beats (float): Number of beats to rest
802
+ beats: Number of beats to rest
935
803
  """
936
804
  sec_per_beat = float(1 / (self.tempo / 60))
937
805
  sec_of_silence = sec_per_beat * beats
@@ -956,41 +824,205 @@ class rosabeats:
956
824
  if self.output_beats:
957
825
  self.write_out("rest %g" % beats)
958
826
 
959
- def play_bar(self, m, reverse=False, silent=False):
960
- """Play a single bar.
961
-
827
+ # -------------------------------------------------------------------------
828
+ # Remix buffer methods
829
+ # -------------------------------------------------------------------------
830
+
831
+ def reset_remix(self) -> None:
832
+ """Reset the remix buffer to initial state."""
833
+ if self.sr is None:
834
+ self.load()
835
+
836
+ if self.remix is not None:
837
+ del self.remix
838
+
839
+ # initializes an array that will hold 30 minutes of audio samples
840
+ length = 30 * 60 * self.sr
841
+ self.remix = np.zeros(shape=(self.channels, length), dtype=self.dtype)
842
+ self.remix_index = 0
843
+
844
+ def _extend_remix(self) -> None:
845
+ """Extend the remix buffer by adding more space."""
846
+ if self.sr is None:
847
+ self.load()
848
+
849
+ rosabeats.d_print()
850
+ rosabeats.d_print("***********extending available space for remixed beats")
851
+ rosabeats.d_print("***********len(remix[0]) before: %s" % len(self.remix[0]))
852
+ # add another 30 minutes
853
+ length = 30 * 60 * self.sr
854
+ extended_array = np.concatenate(
855
+ (self.remix.T, np.zeros(shape=(length, self.channels), dtype=self.dtype)),
856
+ axis=0,
857
+ )
858
+ self.remix = extended_array.T
859
+ rosabeats.d_print("***********len(remix[0]) after: %s" % len(self.remix[0]))
860
+ rosabeats.d_print("******done extending available space for remixed beats")
861
+
862
+ def _copy_to_remix(self, channel: int, data: np.ndarray) -> None:
863
+ """Copy audio data to remix buffer, extending if necessary.
864
+
962
865
  Args:
963
- m (int): Bar number to play
964
- reverse (bool, optional): Play bar in reverse order
965
- silent (bool, optional): Suppress console output
966
-
967
- Raises:
968
- Exception: If beat tracking has not been performed
866
+ channel: Channel index (0 or 1 for stereo)
867
+ data: Audio data to copy
969
868
  """
970
- if self.beatsperbar is None or self.beat_slices is None:
971
- raise Exception("must track beats before you can play bar")
869
+ try:
870
+ self.remix[channel, self.remix_index:self.remix_index + len(data)] += data
871
+ except ValueError:
872
+ self._extend_remix()
873
+ self.remix[channel, self.remix_index:self.remix_index + len(data)] += data
972
874
 
973
- if self.output_beats:
974
- self.write_out("# bar %d" % m)
875
+ def save_remix(self) -> None:
876
+ """Save the remix to the output file."""
877
+ yt, index = librosa.effects.trim(self.remix)
878
+ sf.write(self.remix_output_file, yt.T, self.sr, "PCM_16")
975
879
 
976
- if not silent:
977
- print("[%d]" % m, end="", flush=True)
880
+ # -------------------------------------------------------------------------
881
+ # Feature caching methods
882
+ # -------------------------------------------------------------------------
978
883
 
979
- first_beat = int(m * self.beatsperbar) + self.firstfullbar
980
- last_beat = int(first_beat + self.beatsperbar) - 1
981
- if last_beat > self.total_beats - 1:
982
- last_beat = int(self.total_beats) - 1
884
+ def _has_saved_features(self) -> bool:
885
+ """Check if saved features file exists.
983
886
 
984
- beats = [x for x in range(first_beat, last_beat + 1)]
985
- if reverse:
986
- if not silent:
987
- print("[rev] ", end="", flush=True)
988
- beats.reverse()
887
+ Returns:
888
+ True if saved features file exists and is enabled
889
+ """
890
+ return self.saved_features_enabled and os.path.isfile(self.saved_features)
989
891
 
990
- for beat in beats:
991
- if beat == first_beat:
992
- if not silent:
993
- print("*", end="", flush=True)
994
- self.play_beat(beat)
995
- if not silent:
996
- print(flush=True)
892
+ def _save_features(self) -> None:
893
+ """Save extracted features to file."""
894
+ rosabeats.d_print("saving features...")
895
+
896
+ features = dict()
897
+ features["tempo"] = self.tempo
898
+ features["beatsperbar"] = self.beatsperbar
899
+ features["downbeat"] = self.downbeat
900
+ features["total_beats"] = self.total_beats
901
+ features["total_bars"] = self.total_bars if self.total_bars else None
902
+ features["total_segments"] = self.total_segments
903
+ features["beat_timings"] = self.beat_timings
904
+ features["beat_samples"] = self.beat_samples
905
+ features["beat_slices"] = self.beat_slices
906
+ features["segments"] = self.segments
907
+ # write features
908
+ with open(self.saved_features, "wb") as f:
909
+ joblib.dump(features, f)
910
+
911
+ def _load_saved_features(self) -> None:
912
+ """Load saved features from file."""
913
+ rosabeats.d_print("loading features...")
914
+
915
+ with open(self.saved_features, "rb") as f:
916
+ features = joblib.load(f)
917
+
918
+ self.tempo = features["tempo"]
919
+ self.beatsperbar = features["beatsperbar"]
920
+ self.downbeat = features["downbeat"]
921
+ self.total_beats = features["total_beats"]
922
+ self.total_bars = features["total_bars"]
923
+ self.total_segments = features["total_segments"]
924
+ self.beat_timings = features["beat_timings"]
925
+ self.beat_samples = features["beat_samples"]
926
+ self.beat_slices = features["beat_slices"]
927
+ self.segments = features["segments"]
928
+
929
+ def remove_features_file(self) -> None:
930
+ """Remove the saved features file if it exists."""
931
+ if os.path.isfile(self.saved_features):
932
+ rosabeats.d_print("removing %s" % self.saved_features)
933
+ os.unlink(self.saved_features)
934
+ else:
935
+ rosabeats.d_print("no features file found")
936
+
937
+ # -------------------------------------------------------------------------
938
+ # Convenience methods (merged from scripts)
939
+ # -------------------------------------------------------------------------
940
+
941
+ def shuffle_all_beats(self, output_file: Optional[str] = None) -> None:
942
+ """Shuffle all beats and optionally save to file.
943
+
944
+ Args:
945
+ output_file: Optional path to save output WAV file
946
+ """
947
+ if self.beat_slices is None:
948
+ self.track_beats()
949
+
950
+ beatlist = list(range(self.total_beats))
951
+ random.shuffle(beatlist)
952
+
953
+ if output_file:
954
+ self.enable_output_save(output_file)
955
+ self.reset_remix()
956
+
957
+ self.play_beats(beatlist)
958
+
959
+ if output_file:
960
+ self.save_remix()
961
+
962
+ def shuffle_all_bars(self, output_file: Optional[str] = None) -> None:
963
+ """Shuffle all bars and optionally save to file.
964
+
965
+ Args:
966
+ output_file: Optional path to save output WAV file
967
+ """
968
+ if self.beat_slices is None:
969
+ self.track_beats()
970
+
971
+ barlist = list(range(self.total_bars))
972
+ random.shuffle(barlist)
973
+
974
+ if output_file:
975
+ self.enable_output_save(output_file)
976
+ self.reset_remix()
977
+
978
+ self.play_bars(barlist)
979
+
980
+ if output_file:
981
+ self.save_remix()
982
+
983
+ def reverse_beats_in_all_bars(self, output_file: Optional[str] = None) -> None:
984
+ """Play all bars with beats reversed within each bar.
985
+
986
+ Args:
987
+ output_file: Optional path to save output WAV file
988
+ """
989
+ if self.beat_slices is None:
990
+ self.track_beats()
991
+
992
+ if output_file:
993
+ self.enable_output_save(output_file)
994
+ self.reset_remix()
995
+
996
+ for bar in range(self.total_bars):
997
+ self.play_bar(bar, reverse=True)
998
+
999
+ if output_file:
1000
+ self.save_remix()
1001
+
1002
+ # -------------------------------------------------------------------------
1003
+ # Utility methods
1004
+ # -------------------------------------------------------------------------
1005
+
1006
+ def write_out(self, text: str) -> None:
1007
+ """Write text to beats output file.
1008
+
1009
+ Args:
1010
+ text: Text to write
1011
+ """
1012
+ if self.beats_output is None:
1013
+ self._start_writing_beats_output()
1014
+
1015
+ self.beats_output.write("%s\n" % text)
1016
+
1017
+ def shutdown(self) -> None:
1018
+ """Clean up and close all output streams."""
1019
+ if self.output_play:
1020
+ self.stream.close()
1021
+ if self.output_save:
1022
+ self.save_remix()
1023
+ if self.output_beats:
1024
+ self.beats_output.close()
1025
+
1026
+ def divide_bars(self) -> None:
1027
+ """Deprecated method that no longer performs any action."""
1028
+ rosabeats.d_print("warning: divide_bars() no longer does anything")