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/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 laplacian, segmentino, or backtrack methods',
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('--method', choices=['laplacian', 'segmentino', 'backtrack'],
86
- default='backtrack', help='Segmentation method to use')
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('--firstfull', type=int, default=0,
92
- help='First full bar number')
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" Method: {args.method}")
156
+ print(f" Max clusters: {args.max_clusters}")
119
157
  print(f" Beats per bar: {args.beatsper}")
120
- print(f" First full bar: {args.firstfull}")
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
- s.track_beats(args.beatsper, args.firstfull)
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(f"Segmenting with {args.method} method")
135
- s.segment(method=args.method, max_clusters=args.max_clusters)
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.firstfullbar}\n")
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.firstfullbar}-{s.firstfullbar + (s.beatsperbar * s.total_bars)})\n")
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 {idx}:{Colors.ENDC}")
158
- print(f" Label: {seg['label']}")
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
- print(f" Bars: {seg['bars'][0]}-{seg['bars'][-1]}")
163
- bars_defs.append(f"def {seg['label']}_{idx} bars {seg['bars'][0]}-{seg['bars'][-1]}")
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 {seg['label']}_{idx}_beats beats {seg['beats'][0]}-{seg['beats'][-1]}\t# dur = {seg['duration']:.2f}s, beats {seg['beats'][0]}-{seg['beats'][-1]}")
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.1.3
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.9
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: vamp
31
- Requires-Dist: vamp; extra == "vamp"
32
- Provides-Extra: all
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.9+ (Tested most recently with Python 3.11.12 and 3.13.2)
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 using librosa's Laplacian segmentation
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,,
@@ -0,0 +1,2 @@
1
+ rosabeats
2
+ tests
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")