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/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
|
|
8
|
-
import joblib
|
|
8
|
+
from typing import Any, Optional
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
91
|
-
debug
|
|
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.
|
|
103
|
-
self.
|
|
104
|
-
self.
|
|
105
|
-
self.
|
|
106
|
-
self.
|
|
107
|
-
self.
|
|
108
|
-
self.
|
|
109
|
-
self.
|
|
110
|
-
self.
|
|
111
|
-
self.
|
|
112
|
-
self.
|
|
113
|
-
self.
|
|
114
|
-
self.
|
|
115
|
-
self.
|
|
116
|
-
self.
|
|
117
|
-
self.
|
|
118
|
-
self.
|
|
119
|
-
self.
|
|
120
|
-
self.
|
|
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
|
|
136
|
+
if infile is not None:
|
|
127
137
|
self.setfile(infile)
|
|
128
138
|
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
143
|
+
@property
|
|
144
|
+
def beats_per_bar(self) -> Optional[int]:
|
|
145
|
+
"""Alias for beatsperbar for consistent naming."""
|
|
146
|
+
return self.beatsperbar
|
|
229
147
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
203
|
+
self._load_librosa()
|
|
327
204
|
elif ext == ".ogg":
|
|
328
205
|
rosabeats.d_print("loading via soundfile")
|
|
329
|
-
self.
|
|
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.
|
|
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
|
-
|
|
346
|
-
|
|
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,
|
|
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
|
|
403
|
-
|
|
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.
|
|
406
|
-
self.
|
|
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.
|
|
423
|
-
self.total_bars = int((self.total_beats - self.firstfullbar) / self.beatsperbar)
|
|
251
|
+
self.downbeat = downbeat
|
|
424
252
|
|
|
425
|
-
self.
|
|
253
|
+
self.total_bars = int((self.total_beats - self.downbeat) / self.beatsperbar)
|
|
426
254
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
264
|
+
beatsper: Number of beats per bar
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Beat index of the detected first downbeat
|
|
464
268
|
"""
|
|
465
|
-
|
|
466
|
-
self.track_beats()
|
|
269
|
+
from rosabeats.downbeat import detect_downbeat_dbn
|
|
467
270
|
|
|
468
|
-
if
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
279
|
+
downbeat_idx = detect_downbeat_dbn(
|
|
280
|
+
self.mono, self.sr, self.beat_timings, beats_per_bar=beatsper
|
|
281
|
+
)
|
|
480
282
|
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
488
|
-
|
|
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
|
-
|
|
289
|
+
# -------------------------------------------------------------------------
|
|
290
|
+
# Segmentation methods
|
|
291
|
+
# -------------------------------------------------------------------------
|
|
496
292
|
|
|
497
|
-
|
|
293
|
+
def segment(self, redo: bool = False, max_clusters: int = 48) -> None:
|
|
294
|
+
"""Segment the audio file using Laplacian spectral clustering.
|
|
498
295
|
|
|
499
|
-
|
|
500
|
-
|
|
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
|
|
507
|
-
max_clusters
|
|
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
|
|
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(
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
segment
|
|
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.
|
|
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
|
-
|
|
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
|
|
758
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
532
|
+
# -------------------------------------------------------------------------
|
|
533
|
+
# Bar/beat calculation methods
|
|
534
|
+
# -------------------------------------------------------------------------
|
|
788
535
|
|
|
789
|
-
def
|
|
790
|
-
"""
|
|
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
|
-
|
|
540
|
+
beatnum: Beat number to check
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
Bar number if beat starts a bar, None otherwise
|
|
798
544
|
"""
|
|
799
|
-
self.
|
|
545
|
+
if (beatnum - self.downbeat) % self.beatsperbar == 0:
|
|
546
|
+
return (beatnum - self.downbeat) // self.beatsperbar
|
|
547
|
+
else:
|
|
548
|
+
return None
|
|
800
549
|
|
|
801
|
-
def
|
|
802
|
-
"""
|
|
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
|
-
|
|
808
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
|
819
|
-
"""
|
|
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.
|
|
651
|
+
self.setup_playback()
|
|
822
652
|
if self.output_save:
|
|
823
|
-
self.
|
|
653
|
+
self.reset_remix()
|
|
824
654
|
if self.output_beats:
|
|
825
|
-
self.
|
|
655
|
+
self._start_writing_beats_output()
|
|
826
656
|
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
843
|
-
silent
|
|
844
|
-
divisor
|
|
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
|
-
|
|
881
|
-
|
|
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
|
|
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
|
|
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
|
|
925
|
-
reverse
|
|
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
|
|
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
|
-
|
|
960
|
-
|
|
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
|
-
|
|
964
|
-
|
|
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
|
-
|
|
971
|
-
|
|
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
|
-
|
|
974
|
-
|
|
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
|
-
|
|
977
|
-
|
|
880
|
+
# -------------------------------------------------------------------------
|
|
881
|
+
# Feature caching methods
|
|
882
|
+
# -------------------------------------------------------------------------
|
|
978
883
|
|
|
979
|
-
|
|
980
|
-
|
|
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
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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")
|