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/segment_song.py
CHANGED
|
@@ -75,27 +75,72 @@ def print_warning(text):
|
|
|
75
75
|
"""Print a formatted warning message"""
|
|
76
76
|
print(f"{Colors.YELLOW}{Symbols.WARNING} {text}{Colors.ENDC}")
|
|
77
77
|
|
|
78
|
+
def get_segment_letter(cluster_num):
|
|
79
|
+
"""Convert cluster number to letter (0->A, 1->B, ..., 25->Z, 26->AA, etc.)"""
|
|
80
|
+
if cluster_num < 26:
|
|
81
|
+
return chr(ord('A') + cluster_num)
|
|
82
|
+
else:
|
|
83
|
+
# For clusters beyond 25, use AA, AB, etc.
|
|
84
|
+
first = cluster_num // 26 - 1
|
|
85
|
+
second = cluster_num % 26
|
|
86
|
+
return chr(ord('A') + first) + chr(ord('A') + second)
|
|
87
|
+
|
|
88
|
+
def generate_segment_names(segments):
|
|
89
|
+
"""Generate letter-based names for segments with repeat markers.
|
|
90
|
+
|
|
91
|
+
First occurrence of cluster 0 -> "A"
|
|
92
|
+
Second occurrence of cluster 0 -> "A2"
|
|
93
|
+
Third occurrence -> "A3"
|
|
94
|
+
First occurrence of cluster 1 -> "B", etc.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
list: List of segment names in order
|
|
98
|
+
"""
|
|
99
|
+
# Track how many times we've seen each cluster label
|
|
100
|
+
cluster_counts = {}
|
|
101
|
+
names = []
|
|
102
|
+
|
|
103
|
+
for seg in segments:
|
|
104
|
+
cluster = seg['label']
|
|
105
|
+
if cluster not in cluster_counts:
|
|
106
|
+
cluster_counts[cluster] = 0
|
|
107
|
+
|
|
108
|
+
# Get base letter for this cluster
|
|
109
|
+
letter = get_segment_letter(cluster)
|
|
110
|
+
|
|
111
|
+
# Add number suffix for repeats (A, A2, A3, ...)
|
|
112
|
+
repeat_count = cluster_counts[cluster]
|
|
113
|
+
if repeat_count > 0:
|
|
114
|
+
name = letter + str(repeat_count + 1)
|
|
115
|
+
else:
|
|
116
|
+
name = letter
|
|
117
|
+
|
|
118
|
+
names.append(name)
|
|
119
|
+
cluster_counts[cluster] += 1
|
|
120
|
+
|
|
121
|
+
return names
|
|
122
|
+
|
|
78
123
|
def main(args=None):
|
|
79
124
|
# Set up command line argument parsing
|
|
80
125
|
parser = argparse.ArgumentParser(
|
|
81
|
-
description='Segment audio file and track beats using
|
|
126
|
+
description='Segment audio file and track beats using Laplacian spectral clustering',
|
|
82
127
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
|
83
128
|
)
|
|
84
129
|
parser.add_argument('audiofile', help='Audio file to process')
|
|
85
|
-
parser.add_argument('--
|
|
86
|
-
|
|
87
|
-
parser.add_argument('--max-clusters', type=int, default=None,
|
|
88
|
-
help='Maximum number of clusters to use for laplacian segmentation')
|
|
130
|
+
parser.add_argument('--max-clusters', type=int, default=12,
|
|
131
|
+
help='Maximum number of clusters for segmentation')
|
|
89
132
|
parser.add_argument('--beatsper', type=int, default=8,
|
|
90
133
|
help='Number of beats per bar')
|
|
91
|
-
parser.add_argument('--
|
|
92
|
-
help='
|
|
134
|
+
parser.add_argument('--downbeat', type=int, default=None,
|
|
135
|
+
help='Beat index of first downbeat (default: 0)')
|
|
136
|
+
parser.add_argument('--auto-downbeat', action='store_true',
|
|
137
|
+
help='Auto-detect downbeat using DBN')
|
|
93
138
|
parser.add_argument('--output', help='Output file path (default: input filename with .bri extension)')
|
|
94
|
-
parser.add_argument('--debug', action='store_true',
|
|
139
|
+
parser.add_argument('--debug', action='store_true',
|
|
95
140
|
help='Enable debug mode for detailed processing information')
|
|
96
|
-
|
|
141
|
+
|
|
97
142
|
args = parser.parse_args()
|
|
98
|
-
|
|
143
|
+
|
|
99
144
|
# Set output file if not specified
|
|
100
145
|
if args.output is None:
|
|
101
146
|
basename = os.path.basename(args.audiofile)
|
|
@@ -104,20 +149,19 @@ def main(args=None):
|
|
|
104
149
|
else:
|
|
105
150
|
output = args.output
|
|
106
151
|
|
|
107
|
-
if args.method == "laplacian" and args.max_clusters is None:
|
|
108
|
-
print_warning("max-clusters is not specified for laplacian segmentation, using default of 48")
|
|
109
|
-
args.max_clusters = 48
|
|
110
|
-
|
|
111
|
-
if args.max_clusters is not None and args.method != "laplacian":
|
|
112
|
-
print_warning("max-clusters is specified for non-laplacian segmentation, this will be ignored")
|
|
113
|
-
|
|
114
152
|
# Print processing information
|
|
115
153
|
print_header("Audio Segmentation and Beat Tracking")
|
|
116
154
|
print(f"{Colors.BOLD}Input:{Colors.ENDC}")
|
|
117
155
|
print(f" Audio file: {args.audiofile}")
|
|
118
|
-
print(f"
|
|
156
|
+
print(f" Max clusters: {args.max_clusters}")
|
|
119
157
|
print(f" Beats per bar: {args.beatsper}")
|
|
120
|
-
|
|
158
|
+
if args.downbeat is not None:
|
|
159
|
+
downbeat_str = str(args.downbeat)
|
|
160
|
+
elif args.auto_downbeat:
|
|
161
|
+
downbeat_str = "auto-detect"
|
|
162
|
+
else:
|
|
163
|
+
downbeat_str = "0 (default)"
|
|
164
|
+
print(f" Downbeat: {downbeat_str}")
|
|
121
165
|
print(f" Output file: {output}")
|
|
122
166
|
if args.debug:
|
|
123
167
|
print_warning("Debug mode enabled - detailed processing information will be shown")
|
|
@@ -128,11 +172,25 @@ def main(args=None):
|
|
|
128
172
|
print_success(f"Loaded {os.path.basename(args.audiofile)}")
|
|
129
173
|
|
|
130
174
|
print_step("Tracking beats")
|
|
131
|
-
|
|
175
|
+
# Determine downbeat value
|
|
176
|
+
if args.downbeat is not None:
|
|
177
|
+
downbeat = args.downbeat
|
|
178
|
+
elif args.auto_downbeat:
|
|
179
|
+
# Auto-detect using DBN approach
|
|
180
|
+
s.track_beats(args.beatsper, downbeat=0) # Track first to get beat timings
|
|
181
|
+
print_step("Auto-detecting downbeat using DBN")
|
|
182
|
+
downbeat = s.detect_downbeat_dbn(args.beatsper)
|
|
183
|
+
s.downbeat = downbeat
|
|
184
|
+
s.total_bars = int((s.total_beats - s.downbeat) / s.beatsperbar)
|
|
185
|
+
else:
|
|
186
|
+
downbeat = 0
|
|
187
|
+
|
|
188
|
+
if not args.auto_downbeat:
|
|
189
|
+
s.track_beats(args.beatsper, downbeat)
|
|
132
190
|
print_success(f"Found {s.total_beats} beats in {s.total_bars} bars")
|
|
133
191
|
|
|
134
|
-
print_step(
|
|
135
|
-
s.segment(
|
|
192
|
+
print_step("Segmenting audio")
|
|
193
|
+
s.segment(max_clusters=args.max_clusters)
|
|
136
194
|
print_success(f"Identified {s.total_segments} segments")
|
|
137
195
|
|
|
138
196
|
print_step("Assigning beats and bars to segments")
|
|
@@ -145,26 +203,37 @@ def main(args=None):
|
|
|
145
203
|
# Write header information
|
|
146
204
|
f.write("##BEATS## This was segmented and tracked using librosa's beat tracker\n\n")
|
|
147
205
|
f.write(f"file {s.sourcefile}\n")
|
|
148
|
-
f.write(f"beats_bar {s.beatsperbar} {s.
|
|
206
|
+
f.write(f"beats_bar {s.beatsperbar} {s.downbeat}\n")
|
|
149
207
|
f.write(f"# total beats = {s.total_beats}\n")
|
|
150
|
-
f.write(f"# total bars = {s.total_bars} (beats {s.
|
|
208
|
+
f.write(f"# total bars = {s.total_bars} (beats {s.downbeat}-{s.downbeat + (s.beatsperbar * s.total_bars)})\n")
|
|
209
|
+
|
|
210
|
+
# Generate letter-based segment names
|
|
211
|
+
segment_names = generate_segment_names(s.segments)
|
|
151
212
|
|
|
152
213
|
# Process and write segment information
|
|
153
214
|
bars_defs = []
|
|
154
215
|
beats_defs = []
|
|
155
216
|
for idx, seg in enumerate(s.segments):
|
|
217
|
+
seg_name = segment_names[idx]
|
|
218
|
+
|
|
156
219
|
# Print segment information to console
|
|
157
|
-
print(f"\n{Colors.BOLD}Segment {
|
|
158
|
-
print(f"
|
|
220
|
+
print(f"\n{Colors.BOLD}Segment {seg_name}:{Colors.ENDC}")
|
|
221
|
+
print(f" Cluster: {seg['label']}")
|
|
159
222
|
print(f" Duration: {seg['duration']:.2f} seconds")
|
|
160
|
-
|
|
223
|
+
|
|
161
224
|
if len(seg["bars"]) >= 1:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
225
|
+
# Filter out negative bars (pickup beats before downbeat)
|
|
226
|
+
valid_bars = [b for b in seg["bars"] if b >= 0]
|
|
227
|
+
if len(valid_bars) >= 1:
|
|
228
|
+
print(f" Bars: {valid_bars[0]}-{valid_bars[-1]}")
|
|
229
|
+
bars_defs.append(f"def {seg_name} bars {valid_bars[0]}-{valid_bars[-1]}")
|
|
230
|
+
elif len(seg["bars"]) >= 1:
|
|
231
|
+
# All bars are negative (pickup section)
|
|
232
|
+
print(f" Bars: (pickup) {seg['bars'][0]}-{seg['bars'][-1]}")
|
|
233
|
+
|
|
165
234
|
if len(seg["beats"]) >= 1:
|
|
166
235
|
print(f" Beats: {seg['beats'][0]}-{seg['beats'][-1]}")
|
|
167
|
-
beats_defs.append(f"def {
|
|
236
|
+
beats_defs.append(f"def {seg_name}_beats beats {seg['beats'][0]}-{seg['beats'][-1]}\t# dur = {seg['duration']:.2f}s")
|
|
168
237
|
|
|
169
238
|
# Write segment definitions to file
|
|
170
239
|
for d in bars_defs:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rosabeats
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Audio beat detection, segmentation, and remixing library using librosa
|
|
5
5
|
Author: John Fleming
|
|
6
6
|
License-Expression: ISC
|
|
@@ -8,15 +8,13 @@ Project-URL: Homepage, https://github.com/jbff/rosabeats
|
|
|
8
8
|
Project-URL: Repository, https://github.com/jbff/rosabeats
|
|
9
9
|
Keywords: audio,beat,detection,segmentation,remixing,librosa,music
|
|
10
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
13
11
|
Classifier: Programming Language :: Python :: 3.11
|
|
14
12
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
14
|
Classifier: Operating System :: OS Independent
|
|
17
15
|
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
|
|
18
16
|
Classifier: Topic :: Multimedia :: Sound/Audio :: Conversion
|
|
19
|
-
Requires-Python: >=3.
|
|
17
|
+
Requires-Python: >=3.11
|
|
20
18
|
Description-Content-Type: text/markdown
|
|
21
19
|
License-File: LICENSE.md
|
|
22
20
|
Requires-Dist: librosa<1.0,>=0.11.0
|
|
@@ -27,11 +25,9 @@ Requires-Dist: scipy<2.0,>=1.15
|
|
|
27
25
|
Requires-Dist: scikit-learn<2.0,>=1.5
|
|
28
26
|
Provides-Extra: ffms2
|
|
29
27
|
Requires-Dist: ffms2; extra == "ffms2"
|
|
30
|
-
Provides-Extra:
|
|
31
|
-
Requires-Dist:
|
|
32
|
-
|
|
33
|
-
Requires-Dist: ffms2; extra == "all"
|
|
34
|
-
Requires-Dist: vamp; extra == "all"
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
35
31
|
Dynamic: license-file
|
|
36
32
|
|
|
37
33
|
# rosabeats
|
|
@@ -48,9 +44,7 @@ I wrote most of this code in 2018-2019 and made some additions through 2021. I c
|
|
|
48
44
|
|
|
49
45
|
- **Beat Detection**: Automatically detect beats and tempo in audio files
|
|
50
46
|
- **Bar Analysis**: Group beats into bars with configurable beats-per-bar
|
|
51
|
-
- **Audio Segmentation**: Segment songs into structural parts using
|
|
52
|
-
- Laplacian segmentation (powered by librosa)
|
|
53
|
-
- Segmentino (using Vamp plugins)
|
|
47
|
+
- **Audio Segmentation**: Segment songs into structural parts using Laplacian spectral clustering
|
|
54
48
|
- **Audio Remixing**: Create remixes by manipulating beats and bars:
|
|
55
49
|
- Play individual beats or ranges of beats
|
|
56
50
|
- Play entire bars or ranges of bars
|
|
@@ -66,7 +60,7 @@ I wrote most of this code in 2018-2019 and made some additions through 2021. I c
|
|
|
66
60
|
|
|
67
61
|
### Prerequisites
|
|
68
62
|
|
|
69
|
-
- Python 3.
|
|
63
|
+
- Python 3.11+ (Tested most recently with Python 3.11.12 and 3.13.2)
|
|
70
64
|
- ffms2 libraries (On Fedora: `dnf install ffms2`; tested most recently with ffms-5.0)
|
|
71
65
|
|
|
72
66
|
### Installing from PyPI
|
|
@@ -105,32 +99,17 @@ pip install -e .
|
|
|
105
99
|
|
|
106
100
|
3. Installing optional dependencies:
|
|
107
101
|
|
|
108
|
-
rosabeats has optional dependencies that can be installed based on your needs:
|
|
109
|
-
|
|
110
102
|
```bash
|
|
111
|
-
# Install with Vamp plugin support
|
|
112
|
-
pip install .[vamp]
|
|
113
|
-
|
|
114
103
|
# Install with ffms2 support for additional audio formats
|
|
115
104
|
pip install .[ffms2]
|
|
116
|
-
|
|
117
|
-
# Install with all optional dependencies
|
|
118
|
-
pip install .[all]
|
|
119
105
|
```
|
|
120
106
|
|
|
121
107
|
This will install the package and its dependencies, and make the following command-line tools available:
|
|
122
108
|
- `beatrecipe-processor`: Process beat recipes
|
|
123
|
-
- `segment-song`: Segment songs
|
|
109
|
+
- `segment-song`: Segment songs and track beats
|
|
124
110
|
- `beatswitch`: Generate beat recipes with alternating patterns
|
|
125
111
|
- `rosabeats-shell`: Interactive shell for beat manipulation
|
|
126
112
|
|
|
127
|
-
### Optional: Vamp Plugins for Segmentino
|
|
128
|
-
|
|
129
|
-
If you want to use the Segmentino-based segmentation:
|
|
130
|
-
|
|
131
|
-
1. Download and install the Vamp plugin SDK
|
|
132
|
-
2. Install the Segmentino plugin
|
|
133
|
-
|
|
134
113
|
### Using rosabeats
|
|
135
114
|
|
|
136
115
|
The package can be imported in Python as:
|
|
@@ -153,6 +132,5 @@ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH RE
|
|
|
153
132
|
## Acknowledgments
|
|
154
133
|
|
|
155
134
|
- [librosa](https://librosa.org/) for audio analysis
|
|
156
|
-
- [Vamp plugins](https://www.vamp-plugins.org/) for audio segmentation
|
|
157
135
|
- [Amen](https://github.com/algorithmic-music-exploration/amen) was the original inspiration
|
|
158
136
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
rosabeats/__init__.py,sha256=NK8E9j28o-RhODkxyz8_eAh1vrZCsIWCgMjH4k1ihmE,841
|
|
2
|
+
rosabeats/__main__.py,sha256=mD7v9AKzfGGuNtoeiJHk86viSYSdLnZ68UQEeVMqKaY,1899
|
|
3
|
+
rosabeats/beatrecipe_processor.py,sha256=Qqib_TxTMnf9rxJikGrQKF-gELuMvLXJ5cxMV2yKBLM,17466
|
|
4
|
+
rosabeats/beatswitch.py,sha256=IxnoIJLZ3MnCehZiHdwh4CBUepIAD7aAbPZpGMkgOtA,9000
|
|
5
|
+
rosabeats/downbeat.py,sha256=pr--PY8EoG2kFQ1ZNzaLlIjvWsYXFdq3WkCITwbUQ2I,6500
|
|
6
|
+
rosabeats/rosabeats.py,sha256=pi2ZF1Ee5bP-d0gIuEdx5woIjOJ51cUoVLKynrYuOmQ,36660
|
|
7
|
+
rosabeats/rosabeats_shell.py,sha256=IpFIj_gJlnTpSNBH7tz9Hz6WMiEpE8pLUOneU5QbSso,14083
|
|
8
|
+
rosabeats/segment_song.py,sha256=bNEvVmeNTb2Qqh6yQRoUGbJyOGH4ICwl-TFR8vLJtvo,8725
|
|
9
|
+
rosabeats-0.2.0.dist-info/licenses/LICENSE.md,sha256=aPJeloE50MoONg47hPe8CtvglnNcGO3tsS-S5os4WiA,771
|
|
10
|
+
tests/__init__.py,sha256=K1oWjE0XeOozGuGmu-XE1MmHzSjPMPe8fCIXApdkupQ,23
|
|
11
|
+
tests/conftest.py,sha256=cazq6W3j3jBKo2Swby3NtxZy_SfjAFIhnFEkzG6SXJE,3616
|
|
12
|
+
tests/test_beatrecipe_processor.py,sha256=5XYsyeD_gEk5BzSC5aMuiE6FEQrT8p838wdT7JqUZF4,7014
|
|
13
|
+
tests/test_downbeat.py,sha256=ZbEst4u-ZPBv1nUBXe46GV6spAXkvPfrjh65Hw1GdlY,6028
|
|
14
|
+
tests/test_rosabeats.py,sha256=O4x6grGFV9_zcU9bIqbjLoKkzuMR-Gsou3bv6r1tDWk,6998
|
|
15
|
+
tests/test_segment_song.py,sha256=hJYtdpe9tmBygv_MdDRlmz9dfylQl7v8Quidrm9GOdA,3414
|
|
16
|
+
tests/test_shell.py,sha256=c_IgwQlej-tBPMHCSWvqQ1mxnEora3KoZBL0JAE6grk,10258
|
|
17
|
+
rosabeats-0.2.0.dist-info/METADATA,sha256=nMSRJ8LMQ0AYDzeQNKUV3h333KbdEAXSlrw98cTPp9Q,6325
|
|
18
|
+
rosabeats-0.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
19
|
+
rosabeats-0.2.0.dist-info/entry_points.txt,sha256=4psFMIAY5kogJyT8RMdFeTpAnRHmZpaFPnlwI3tntuk,208
|
|
20
|
+
rosabeats-0.2.0.dist-info/top_level.txt,sha256=3mKBok5LDShHMDBok1_PSp9Y4tGYJn76G61HF-iaBFc,16
|
|
21
|
+
rosabeats-0.2.0.dist-info/RECORD,,
|
tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# rosabeats test suite
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Shared pytest fixtures for rosabeats tests."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.fixture
|
|
8
|
+
def sample_rate():
|
|
9
|
+
"""Standard sample rate for tests."""
|
|
10
|
+
return 22050
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def mono_audio(sample_rate):
|
|
15
|
+
"""Generate a simple mono audio signal (1 second of sine wave)."""
|
|
16
|
+
duration = 1.0
|
|
17
|
+
t = np.linspace(0, duration, int(sample_rate * duration), dtype=np.float32)
|
|
18
|
+
# 440 Hz sine wave
|
|
19
|
+
audio = 0.5 * np.sin(2 * np.pi * 440 * t)
|
|
20
|
+
return audio
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def beat_times():
|
|
25
|
+
"""Sample beat times at 120 BPM (0.5s intervals)."""
|
|
26
|
+
return np.array([0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0, 6.5, 7.0, 7.5])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def synthetic_audio_with_beats(sample_rate):
|
|
31
|
+
"""Generate synthetic audio with clear beats for testing.
|
|
32
|
+
|
|
33
|
+
Creates a 10-second audio clip at 120 BPM with kicks on downbeats
|
|
34
|
+
(every 4 beats) and lighter hits on other beats.
|
|
35
|
+
"""
|
|
36
|
+
duration = 10.0
|
|
37
|
+
n_samples = int(sample_rate * duration)
|
|
38
|
+
audio = np.zeros(n_samples, dtype=np.float32)
|
|
39
|
+
|
|
40
|
+
bpm = 120
|
|
41
|
+
beat_interval = 60.0 / bpm # 0.5 seconds
|
|
42
|
+
beats_per_bar = 4
|
|
43
|
+
|
|
44
|
+
# Create a simple kick drum sound (low frequency pulse)
|
|
45
|
+
kick_duration = 0.05
|
|
46
|
+
kick_samples = int(sample_rate * kick_duration)
|
|
47
|
+
t_kick = np.linspace(0, kick_duration, kick_samples)
|
|
48
|
+
kick = np.sin(2 * np.pi * 60 * t_kick) * np.exp(-t_kick * 40)
|
|
49
|
+
|
|
50
|
+
# Create a lighter hi-hat sound (high frequency noise burst)
|
|
51
|
+
hat_duration = 0.02
|
|
52
|
+
hat_samples = int(sample_rate * hat_duration)
|
|
53
|
+
t_hat = np.linspace(0, hat_duration, hat_samples)
|
|
54
|
+
hat = np.random.randn(hat_samples).astype(np.float32) * 0.1 * np.exp(-t_hat * 100)
|
|
55
|
+
|
|
56
|
+
# Place beats
|
|
57
|
+
beat_num = 0
|
|
58
|
+
current_time = 0.0
|
|
59
|
+
while current_time < duration:
|
|
60
|
+
sample_idx = int(current_time * sample_rate)
|
|
61
|
+
|
|
62
|
+
if beat_num % beats_per_bar == 0:
|
|
63
|
+
# Downbeat - kick drum
|
|
64
|
+
end_idx = min(sample_idx + len(kick), n_samples)
|
|
65
|
+
audio[sample_idx:end_idx] += kick[:end_idx - sample_idx]
|
|
66
|
+
else:
|
|
67
|
+
# Other beat - hi-hat
|
|
68
|
+
end_idx = min(sample_idx + len(hat), n_samples)
|
|
69
|
+
audio[sample_idx:end_idx] += hat[:end_idx - sample_idx]
|
|
70
|
+
|
|
71
|
+
beat_num += 1
|
|
72
|
+
current_time += beat_interval
|
|
73
|
+
|
|
74
|
+
# Normalize
|
|
75
|
+
if np.max(np.abs(audio)) > 0:
|
|
76
|
+
audio = audio / np.max(np.abs(audio)) * 0.8
|
|
77
|
+
|
|
78
|
+
return audio, sample_rate
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.fixture
|
|
82
|
+
def temp_audio_file(tmp_path, synthetic_audio_with_beats):
|
|
83
|
+
"""Create a temporary WAV file for testing.
|
|
84
|
+
|
|
85
|
+
This creates a stereo file for better compatibility with rosabeats.
|
|
86
|
+
"""
|
|
87
|
+
import soundfile as sf
|
|
88
|
+
|
|
89
|
+
audio, sr = synthetic_audio_with_beats
|
|
90
|
+
# Convert to stereo
|
|
91
|
+
stereo_audio = np.column_stack([audio, audio])
|
|
92
|
+
filepath = tmp_path / "test_audio.wav"
|
|
93
|
+
sf.write(filepath, stereo_audio, sr)
|
|
94
|
+
return str(filepath)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.fixture
|
|
98
|
+
def sample_br_content():
|
|
99
|
+
"""Sample beat recipe file content."""
|
|
100
|
+
return """file test.wav
|
|
101
|
+
beats_bar 4 0
|
|
102
|
+
# This is a comment
|
|
103
|
+
def intro beats 0-15
|
|
104
|
+
def verse bars 0-3
|
|
105
|
+
def chorus bars 4-7
|
|
106
|
+
|
|
107
|
+
# Play commands
|
|
108
|
+
play intro
|
|
109
|
+
play verse 2
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@pytest.fixture
|
|
114
|
+
def sample_bri_content():
|
|
115
|
+
"""Sample .bri file content (from segment-song output)."""
|
|
116
|
+
return """##BEATS## This was segmented and tracked using librosa's beat tracker
|
|
117
|
+
|
|
118
|
+
file /path/to/audio.wav
|
|
119
|
+
beats_bar 4 0
|
|
120
|
+
# total beats = 100
|
|
121
|
+
# total bars = 24 (beats 0-96)
|
|
122
|
+
def A bars 0-3
|
|
123
|
+
def B bars 4-7
|
|
124
|
+
def A2 bars 8-11
|
|
125
|
+
def C bars 12-15
|
|
126
|
+
|
|
127
|
+
def A_beats beats 0-15 # dur = 8.00s
|
|
128
|
+
def B_beats beats 16-31 # dur = 8.00s
|
|
129
|
+
def A2_beats beats 32-47 # dur = 8.00s
|
|
130
|
+
def C_beats beats 48-63 # dur = 8.00s
|
|
131
|
+
"""
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Tests for beatrecipe_processor module."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from rosabeats.beatrecipe_processor import beatrecipe_processor
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestBeatrecipeProcessorIlist:
|
|
9
|
+
"""Tests for ilist static method."""
|
|
10
|
+
|
|
11
|
+
def test_ascending_range(self):
|
|
12
|
+
"""Should create list from ascending range tuple."""
|
|
13
|
+
result = beatrecipe_processor.ilist((3, 7, 1))
|
|
14
|
+
assert result == [3, 4, 5, 6, 7]
|
|
15
|
+
|
|
16
|
+
def test_descending_range(self):
|
|
17
|
+
"""Should create list from descending range tuple."""
|
|
18
|
+
result = beatrecipe_processor.ilist((7, 3, -1))
|
|
19
|
+
assert result == [7, 6, 5, 4, 3]
|
|
20
|
+
|
|
21
|
+
def test_single_element(self):
|
|
22
|
+
"""Should handle single element range."""
|
|
23
|
+
result = beatrecipe_processor.ilist((5, 5, 1))
|
|
24
|
+
assert result == [5]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestBeatrecipeProcessorParseFirstLast:
|
|
28
|
+
"""Tests for parse_first_last static method."""
|
|
29
|
+
|
|
30
|
+
def test_single_number(self):
|
|
31
|
+
"""Should parse single number."""
|
|
32
|
+
result = beatrecipe_processor.parse_first_last("5")
|
|
33
|
+
assert result == (5, 5, 1)
|
|
34
|
+
|
|
35
|
+
def test_ascending_range(self):
|
|
36
|
+
"""Should parse ascending range."""
|
|
37
|
+
result = beatrecipe_processor.parse_first_last("3-7")
|
|
38
|
+
assert result == (3, 7, 1)
|
|
39
|
+
|
|
40
|
+
def test_descending_range(self):
|
|
41
|
+
"""Should parse descending range with negative step."""
|
|
42
|
+
result = beatrecipe_processor.parse_first_last("7-3")
|
|
43
|
+
assert result == (7, 3, -1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestBeatrecipeProcessorPreprocess:
|
|
47
|
+
"""Tests for preprocess method."""
|
|
48
|
+
|
|
49
|
+
@pytest.fixture
|
|
50
|
+
def processor(self, tmp_path, sample_br_content):
|
|
51
|
+
"""Create a processor instance with a recipe file."""
|
|
52
|
+
recipe_file = tmp_path / "test.br"
|
|
53
|
+
recipe_file.write_text(sample_br_content)
|
|
54
|
+
return beatrecipe_processor(str(recipe_file))
|
|
55
|
+
|
|
56
|
+
def test_empty_line(self, processor):
|
|
57
|
+
"""Should return None for empty line."""
|
|
58
|
+
result = processor.preprocess("")
|
|
59
|
+
assert result is None
|
|
60
|
+
|
|
61
|
+
def test_whitespace_only(self, processor):
|
|
62
|
+
"""Should return None for whitespace only."""
|
|
63
|
+
result = processor.preprocess(" ")
|
|
64
|
+
assert result is None
|
|
65
|
+
|
|
66
|
+
def test_comment_line(self, processor):
|
|
67
|
+
"""Should return None for comment line."""
|
|
68
|
+
result = processor.preprocess("# this is a comment")
|
|
69
|
+
assert result is None
|
|
70
|
+
|
|
71
|
+
def test_simple_command(self, processor):
|
|
72
|
+
"""Should return list with single command."""
|
|
73
|
+
result = processor.preprocess("beats 0-7")
|
|
74
|
+
assert result == ["beats 0-7"]
|
|
75
|
+
|
|
76
|
+
def test_inline_comment_stripped(self, processor):
|
|
77
|
+
"""Should strip inline comments."""
|
|
78
|
+
result = processor.preprocess("beats 0-7 # play intro")
|
|
79
|
+
assert result == ["beats 0-7"]
|
|
80
|
+
|
|
81
|
+
def test_semicolon_splits_commands(self, processor):
|
|
82
|
+
"""Should split commands on semicolons."""
|
|
83
|
+
result = processor.preprocess("beats 0-3; beats 4-7")
|
|
84
|
+
assert result == ["beats 0-3", "beats 4-7"]
|
|
85
|
+
|
|
86
|
+
def test_multiple_semicolons(self, processor):
|
|
87
|
+
"""Should handle multiple semicolons."""
|
|
88
|
+
result = processor.preprocess("a; b; c")
|
|
89
|
+
assert result == ["a", "b", "c"]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestBeatrecipeProcessorMacros:
|
|
93
|
+
"""Tests for macro functionality."""
|
|
94
|
+
|
|
95
|
+
@pytest.fixture
|
|
96
|
+
def processor(self, tmp_path, sample_br_content):
|
|
97
|
+
"""Create a processor instance with a recipe file."""
|
|
98
|
+
recipe_file = tmp_path / "test.br"
|
|
99
|
+
recipe_file.write_text(sample_br_content)
|
|
100
|
+
return beatrecipe_processor(str(recipe_file))
|
|
101
|
+
|
|
102
|
+
def test_define_macro(self, processor):
|
|
103
|
+
"""Should define a macro."""
|
|
104
|
+
processor.define_macro("test", "beats 0-7")
|
|
105
|
+
assert processor.is_defined_macro("test")
|
|
106
|
+
assert processor.macros["test"] == "beats 0-7"
|
|
107
|
+
|
|
108
|
+
def test_is_defined_macro_false(self, processor):
|
|
109
|
+
"""Should return False for undefined macro."""
|
|
110
|
+
assert not processor.is_defined_macro("undefined")
|
|
111
|
+
|
|
112
|
+
def test_redefine_macro(self, processor):
|
|
113
|
+
"""Should allow redefining a macro."""
|
|
114
|
+
processor.define_macro("test", "beats 0-7")
|
|
115
|
+
processor.define_macro("test", "bars 0-3")
|
|
116
|
+
assert processor.macros["test"] == "bars 0-3"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestBeatrecipeProcessorParseCommand:
|
|
120
|
+
"""Tests for parse_command method."""
|
|
121
|
+
|
|
122
|
+
@pytest.fixture
|
|
123
|
+
def processor(self, tmp_path, sample_br_content):
|
|
124
|
+
"""Create a processor instance with a recipe file."""
|
|
125
|
+
recipe_file = tmp_path / "test.br"
|
|
126
|
+
recipe_file.write_text(sample_br_content)
|
|
127
|
+
p = beatrecipe_processor(str(recipe_file))
|
|
128
|
+
p.interactive = False
|
|
129
|
+
return p
|
|
130
|
+
|
|
131
|
+
def test_parse_beats_command(self, processor):
|
|
132
|
+
"""Should parse beats command."""
|
|
133
|
+
verb, args = processor.parse_command("beats 0-7")
|
|
134
|
+
assert verb == "beats"
|
|
135
|
+
assert args == ["0-7"]
|
|
136
|
+
|
|
137
|
+
def test_parse_bars_command(self, processor):
|
|
138
|
+
"""Should parse bars command."""
|
|
139
|
+
verb, args = processor.parse_command("bars 0-3")
|
|
140
|
+
assert verb == "bars"
|
|
141
|
+
assert args == ["0-3"]
|
|
142
|
+
|
|
143
|
+
def test_parse_def_command(self, processor):
|
|
144
|
+
"""Should parse def command."""
|
|
145
|
+
verb, args = processor.parse_command("def intro beats 0-7")
|
|
146
|
+
assert verb == "def"
|
|
147
|
+
assert args == ["intro", "beats", "0-7"]
|
|
148
|
+
|
|
149
|
+
def test_parse_play_command(self, processor):
|
|
150
|
+
"""Should parse play command."""
|
|
151
|
+
verb, args = processor.parse_command("play intro 2")
|
|
152
|
+
assert verb == "play"
|
|
153
|
+
assert args == ["intro", "2"]
|
|
154
|
+
|
|
155
|
+
def test_parse_rest_command(self, processor):
|
|
156
|
+
"""Should parse rest command."""
|
|
157
|
+
verb, args = processor.parse_command("rest 1.5")
|
|
158
|
+
assert verb == "rest"
|
|
159
|
+
assert args == ["1.5"]
|
|
160
|
+
|
|
161
|
+
def test_parse_file_command(self, processor):
|
|
162
|
+
"""Should parse file command."""
|
|
163
|
+
verb, args = processor.parse_command("file /path/to/audio.wav")
|
|
164
|
+
assert verb == "file"
|
|
165
|
+
assert args == ["/path/to/audio.wav"]
|
|
166
|
+
|
|
167
|
+
def test_parse_beats_bar_command(self, processor):
|
|
168
|
+
"""Should parse beats_bar command."""
|
|
169
|
+
verb, args = processor.parse_command("beats_bar 4 0")
|
|
170
|
+
assert verb == "beats_bar"
|
|
171
|
+
assert args == ["4", "0"]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class TestBeatrecipeProcessorInit:
|
|
175
|
+
"""Tests for processor initialization."""
|
|
176
|
+
|
|
177
|
+
def test_init_with_recipe(self, tmp_path, sample_br_content):
|
|
178
|
+
"""Should initialize with recipe file."""
|
|
179
|
+
recipe_file = tmp_path / "test.br"
|
|
180
|
+
recipe_file.write_text(sample_br_content)
|
|
181
|
+
p = beatrecipe_processor(str(recipe_file))
|
|
182
|
+
assert p.macros is not None
|
|
183
|
+
assert p.interactive is False
|
|
184
|
+
|
|
185
|
+
def test_loads_macros_from_recipe(self, tmp_path, sample_br_content):
|
|
186
|
+
"""Should load macro definitions from recipe file."""
|
|
187
|
+
recipe_file = tmp_path / "test.br"
|
|
188
|
+
recipe_file.write_text(sample_br_content)
|
|
189
|
+
p = beatrecipe_processor(str(recipe_file))
|
|
190
|
+
# The sample_br_content defines 'intro', 'verse', 'chorus'
|
|
191
|
+
assert p.is_defined_macro("intro")
|
|
192
|
+
assert p.is_defined_macro("verse")
|
|
193
|
+
assert p.is_defined_macro("chorus")
|