breaks-machine 0.1.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.
@@ -0,0 +1,3 @@
1
+ """breaks-machine package."""
2
+
3
+ __version__ = "0.1.0"
breaks_machine/cli.py ADDED
@@ -0,0 +1,190 @@
1
+ """Command-line interface for breaks-machine."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from .processor import (
8
+ ProcessingOptions,
9
+ is_audio_file,
10
+ parse_targets,
11
+ process_directory,
12
+ process_file,
13
+ )
14
+ from .stretcher import RubberbandNotFoundError, check_rubberband_installed
15
+
16
+
17
+ @click.group()
18
+ @click.version_option()
19
+ def cli():
20
+ """breaks-machine: Time-stretch drum breaks to target BPMs."""
21
+ pass
22
+
23
+
24
+ @cli.command()
25
+ @click.argument("input_path", type=click.Path(exists=True, path_type=Path))
26
+ @click.option(
27
+ "-t",
28
+ "--target",
29
+ type=float,
30
+ help="Single target BPM",
31
+ )
32
+ @click.option(
33
+ "--targets",
34
+ type=str,
35
+ help="Comma-separated target BPMs (e.g., 90,120,140)",
36
+ )
37
+ @click.option(
38
+ "-r",
39
+ "--range",
40
+ "range_spec",
41
+ type=str,
42
+ help="BPM range (e.g., 80-160)",
43
+ )
44
+ @click.option(
45
+ "-s",
46
+ "--step",
47
+ type=int,
48
+ default=10,
49
+ show_default=True,
50
+ help="Step size for range",
51
+ )
52
+ @click.option(
53
+ "-b",
54
+ "--bpm",
55
+ type=float,
56
+ help="Manual source BPM override",
57
+ )
58
+ @click.option(
59
+ "-o",
60
+ "--output",
61
+ type=click.Path(path_type=Path),
62
+ default=Path("./output"),
63
+ show_default=True,
64
+ help="Output directory",
65
+ )
66
+ @click.option(
67
+ "--sample-rate",
68
+ type=int,
69
+ help="Target sample rate (e.g., 44100, 48000)",
70
+ )
71
+ @click.option(
72
+ "--bit-depth",
73
+ type=click.Choice(["16", "24"]),
74
+ help="Target bit depth",
75
+ )
76
+ @click.option(
77
+ "--mono",
78
+ is_flag=True,
79
+ help="Convert to mono",
80
+ )
81
+ @click.option(
82
+ "-w",
83
+ "--warn",
84
+ is_flag=True,
85
+ help="Warn if detected BPM differs from filename",
86
+ )
87
+ @click.option(
88
+ "--crispness",
89
+ type=click.IntRange(0, 6),
90
+ default=5,
91
+ show_default=True,
92
+ help="Rubberband crispness (0-6, higher preserves transients)",
93
+ )
94
+ def stretch(
95
+ input_path: Path,
96
+ target: float | None,
97
+ targets: str | None,
98
+ range_spec: str | None,
99
+ step: int,
100
+ bpm: float | None,
101
+ output: Path,
102
+ sample_rate: int | None,
103
+ bit_depth: str | None,
104
+ mono: bool,
105
+ warn: bool,
106
+ crispness: int,
107
+ ):
108
+ """
109
+ Time-stretch audio file(s) to target BPM(s).
110
+
111
+ INPUT_PATH can be a single audio file or a directory containing audio files.
112
+
113
+ Examples:
114
+
115
+ # Single file, single target
116
+ breaks-machine stretch break.wav --target 140
117
+
118
+ # Multiple targets
119
+ breaks-machine stretch break.wav --targets 90,120,140
120
+
121
+ # Range of targets
122
+ breaks-machine stretch break.wav --range 80-160 --step 10
123
+
124
+ # Batch process directory
125
+ breaks-machine stretch ./breaks/ --target 140
126
+
127
+ # With format conversion
128
+ breaks-machine stretch break.wav -t 140 --sample-rate 44100 --mono
129
+ """
130
+ # Check rubberband is installed
131
+ try:
132
+ check_rubberband_installed()
133
+ except RubberbandNotFoundError as e:
134
+ raise click.ClickException(str(e)) from None
135
+
136
+ # Parse targets
137
+ try:
138
+ target_bpms = parse_targets(target, targets, range_spec, step)
139
+ except ValueError as e:
140
+ raise click.ClickException(str(e)) from None
141
+
142
+ # Build options
143
+ options = ProcessingOptions(
144
+ manual_bpm=bpm,
145
+ sample_rate=sample_rate,
146
+ bit_depth=int(bit_depth) if bit_depth else None,
147
+ mono=mono,
148
+ warn=warn,
149
+ crispness=crispness,
150
+ )
151
+
152
+ click.echo(f"Target BPM(s): {', '.join(str(int(t)) for t in target_bpms)}")
153
+ click.echo(f"Output directory: {output}")
154
+ click.echo()
155
+
156
+ try:
157
+ if input_path.is_dir():
158
+ outputs = process_directory(
159
+ input_path,
160
+ target_bpms,
161
+ output,
162
+ options,
163
+ echo=click.echo,
164
+ )
165
+ elif is_audio_file(input_path):
166
+ outputs = process_file(
167
+ input_path,
168
+ target_bpms,
169
+ output,
170
+ options,
171
+ echo=click.echo,
172
+ )
173
+ else:
174
+ raise click.ClickException(
175
+ f"Unsupported file type: {input_path.suffix}. Supported: .wav, .flac"
176
+ )
177
+ except Exception as e:
178
+ raise click.ClickException(str(e)) from None
179
+
180
+ click.echo()
181
+ click.echo(f"Created {len(outputs)} file(s)")
182
+
183
+
184
+ def main():
185
+ """Entry point for the CLI."""
186
+ cli()
187
+
188
+
189
+ if __name__ == "__main__":
190
+ main()
@@ -0,0 +1,95 @@
1
+ """Audio format conversion utilities."""
2
+
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+ import soundfile as sf
7
+
8
+ # Map bit depth to soundfile subtype
9
+ BIT_DEPTH_TO_SUBTYPE = {
10
+ 16: "PCM_16",
11
+ 24: "PCM_24",
12
+ 32: "PCM_32",
13
+ }
14
+
15
+
16
+ def convert_audio(
17
+ input_path: Path,
18
+ output_path: Path | None = None,
19
+ sample_rate: int | None = None,
20
+ bit_depth: int | None = None,
21
+ mono: bool = False,
22
+ ) -> Path:
23
+ """
24
+ Convert audio file format, sample rate, bit depth, and/or channels.
25
+
26
+ Args:
27
+ input_path: Path to input audio file
28
+ output_path: Path for output file (defaults to overwriting input)
29
+ sample_rate: Target sample rate in Hz (e.g., 44100, 48000)
30
+ bit_depth: Target bit depth (16 or 24)
31
+ mono: Convert to mono if True
32
+
33
+ Returns:
34
+ Path to the output file
35
+ """
36
+ if output_path is None:
37
+ output_path = input_path
38
+
39
+ # Load audio
40
+ y, sr = sf.read(input_path)
41
+ input_info = sf.info(input_path)
42
+
43
+ # Track if any conversion is needed
44
+ needs_conversion = False
45
+
46
+ # Resample if needed
47
+ if sample_rate is not None and sample_rate != sr:
48
+ needs_conversion = True
49
+ # Use soxr for high-quality resampling (installed with librosa)
50
+ import soxr
51
+
52
+ y = soxr.resample(y, sr, sample_rate)
53
+ sr = sample_rate
54
+
55
+ # Convert to mono if needed
56
+ if mono and len(y.shape) > 1 and y.shape[1] > 1:
57
+ needs_conversion = True
58
+ y = np.mean(y, axis=1)
59
+
60
+ # Determine output subtype
61
+ if bit_depth is not None:
62
+ needs_conversion = True
63
+ subtype = BIT_DEPTH_TO_SUBTYPE.get(bit_depth)
64
+ if subtype is None:
65
+ raise ValueError(f"Unsupported bit depth: {bit_depth}. Use 16, 24, or 32.")
66
+ else:
67
+ subtype = input_info.subtype
68
+
69
+ # Only write if conversion was needed or output path differs
70
+ if needs_conversion or output_path != input_path:
71
+ output_path.parent.mkdir(parents=True, exist_ok=True)
72
+ sf.write(output_path, y, sr, subtype=subtype)
73
+
74
+ return output_path
75
+
76
+
77
+ def get_audio_info(file_path: Path) -> dict:
78
+ """
79
+ Get audio file information.
80
+
81
+ Args:
82
+ file_path: Path to audio file
83
+
84
+ Returns:
85
+ Dictionary with audio properties
86
+ """
87
+ info = sf.info(file_path)
88
+ return {
89
+ "sample_rate": info.samplerate,
90
+ "channels": info.channels,
91
+ "frames": info.frames,
92
+ "duration": info.duration,
93
+ "format": info.format,
94
+ "subtype": info.subtype,
95
+ }
@@ -0,0 +1,194 @@
1
+ """BPM detection from filenames and audio analysis."""
2
+
3
+ import re
4
+ from collections.abc import Callable
5
+ from pathlib import Path
6
+
7
+ import librosa
8
+
9
+
10
+ class BPMDetectionError(Exception):
11
+ """Raised when BPM cannot be determined."""
12
+
13
+ pass
14
+
15
+
16
+ def parse_bpm_from_filename(file_path: Path) -> float | None:
17
+ """
18
+ Extract BPM from filename using common naming conventions.
19
+
20
+ Matches patterns like:
21
+ - amen_170.wav -> 170
22
+ - break-85bpm.flac -> 85
23
+ - think_120_BPM.wav -> 120
24
+ - funky_90-bpm.wav -> 90
25
+ - drum_loop_140BPM.wav -> 140
26
+
27
+ Returns None if no BPM found in filename.
28
+ """
29
+ filename = file_path.stem
30
+
31
+ # Pattern: number followed by optional separator and "bpm" (case insensitive)
32
+ # e.g., "85bpm", "85-bpm", "85_BPM", "85 bpm"
33
+ pattern_with_bpm = r"(\d{2,3})[\s_-]?bpm"
34
+ match = re.search(pattern_with_bpm, filename, re.IGNORECASE)
35
+ if match:
36
+ return float(match.group(1))
37
+
38
+ # Pattern: underscore/hyphen followed by 2-3 digit number at end or before extension
39
+ # e.g., "amen_170", "break-85"
40
+ pattern_trailing = r"[_-](\d{2,3})(?:[_-]|$)"
41
+ match = re.search(pattern_trailing, filename)
42
+ if match:
43
+ bpm = float(match.group(1))
44
+ # Sanity check: BPM should be in reasonable range (50-250)
45
+ if 50 <= bpm <= 250:
46
+ return bpm
47
+
48
+ return None
49
+
50
+
51
+ def detect_bpm_with_librosa(file_path: Path) -> float:
52
+ """
53
+ Detect BPM using librosa's beat tracking with multiple strategies.
54
+
55
+ Uses multiple tempo priors and subdivision correction to improve accuracy
56
+ on complex breakbeats.
57
+
58
+ Returns the estimated BPM.
59
+ """
60
+ y, sr = librosa.load(file_path, sr=None)
61
+
62
+ # Strategy 1: Try multiple starting priors
63
+ # Default (120), medium (140), and high (170) to cover different tempo ranges
64
+ priors = [None, 140, 170]
65
+ all_candidates = []
66
+
67
+ for prior in priors:
68
+ if prior is None:
69
+ tempo = librosa.feature.tempo(y=y, sr=sr, aggregate=None)
70
+ else:
71
+ tempo = librosa.feature.tempo(y=y, sr=sr, start_bpm=prior, aggregate=None)
72
+
73
+ # Extract candidates
74
+ if hasattr(tempo, "__len__") and len(tempo) > 0:
75
+ all_candidates.extend([float(t) for t in tempo[:4]])
76
+ else:
77
+ all_candidates.append(float(tempo))
78
+
79
+ # Remove duplicates
80
+ candidates = sorted(set(all_candidates))
81
+
82
+ # Strategy 2: For each candidate, consider common subdivision relationships
83
+ # Breakbeat detection often finds subdivisions (2/3, 1/2, 3/4 time)
84
+ expanded_candidates = []
85
+ for bpm in candidates:
86
+ expanded_candidates.append(bpm)
87
+ expanded_candidates.append(bpm * 2) # Double-time (2x)
88
+ expanded_candidates.append(bpm * 1.5) # 3/2 time
89
+ expanded_candidates.append(bpm * (4 / 3)) # 4/3 time
90
+ expanded_candidates.append(bpm / 1.5) # 2/3 time (detected too high)
91
+
92
+ # Remove duplicates and filter to reasonable range (80-200 BPM)
93
+ valid_candidates = sorted(set(bpm for bpm in expanded_candidates if 80 <= bpm <= 200))
94
+
95
+ if not valid_candidates:
96
+ # Fallback to original candidate if nothing in range
97
+ return candidates[0] if candidates else 120.0
98
+
99
+ # Strategy 3: Prefer direct detections over derived subdivisions
100
+ # Group candidates by how they were obtained
101
+ direct_detections = [bpm for bpm in candidates if 80 <= bpm <= 200]
102
+
103
+ if direct_detections:
104
+ # Prefer candidates that were directly detected (not subdivisions)
105
+ # Return the one closest to the common breakbeat range (140-180)
106
+ target = 160
107
+ best = min(direct_detections, key=lambda x: abs(x - target))
108
+ return best
109
+ else:
110
+ # Fall back to derived candidates if no direct detection in range
111
+ target = 160
112
+ best = min(valid_candidates, key=lambda x: abs(x - target))
113
+ return best
114
+
115
+
116
+ def bpms_match(bpm1: float, bpm2: float, tolerance: float = 3.0) -> bool:
117
+ """
118
+ Check if two BPMs match, accounting for 2x/0.5x detection differences.
119
+
120
+ Args:
121
+ bpm1: First BPM value
122
+ bpm2: Second BPM value
123
+ tolerance: Acceptable difference in BPM (default 3.0)
124
+
125
+ Returns:
126
+ True if BPMs match (including half/double time variants)
127
+ """
128
+ # Direct match
129
+ if abs(bpm1 - bpm2) <= tolerance:
130
+ return True
131
+
132
+ # Check half time (bpm2 is half of bpm1)
133
+ if abs(bpm1 - bpm2 * 2) <= tolerance:
134
+ return True
135
+
136
+ # Check double time (bpm2 is double of bpm1)
137
+ if abs(bpm1 * 2 - bpm2) <= tolerance:
138
+ return True
139
+
140
+ return False
141
+
142
+
143
+ def get_source_bpm(
144
+ file_path: Path,
145
+ manual_bpm: float | None = None,
146
+ warn: bool = False,
147
+ warn_callback: Callable[[str], None] | None = None,
148
+ ) -> float:
149
+ """
150
+ Determine source BPM using priority: manual > filename > auto-detect.
151
+
152
+ Args:
153
+ file_path: Path to audio file
154
+ manual_bpm: User-specified BPM override (highest priority)
155
+ warn: Whether to warn if detected BPM differs from filename
156
+ warn_callback: Function to call with warning message
157
+
158
+ Returns:
159
+ Detected or specified BPM
160
+
161
+ Raises:
162
+ BPMDetectionError: If no BPM source available
163
+ """
164
+ # 1. Manual override wins
165
+ if manual_bpm is not None:
166
+ return manual_bpm
167
+
168
+ # 2. Try filename parsing
169
+ filename_bpm = parse_bpm_from_filename(file_path)
170
+
171
+ # 3. Auto-detect as fallback (or for validation)
172
+ detected_bpm = None
173
+ if filename_bpm is None or warn:
174
+ try:
175
+ detected_bpm = detect_bpm_with_librosa(file_path)
176
+ except Exception as e:
177
+ if filename_bpm is None:
178
+ raise BPMDetectionError(f"Could not determine BPM for {file_path}: {e}") from e
179
+
180
+ # Return filename BPM if available
181
+ if filename_bpm is not None:
182
+ # Warn if detection differs (ignoring 2x/0.5x)
183
+ if warn and detected_bpm is not None and warn_callback:
184
+ if not bpms_match(filename_bpm, detected_bpm):
185
+ warn_callback(
186
+ f"Filename suggests {filename_bpm} BPM, but detected {detected_bpm:.1f} BPM"
187
+ )
188
+ return filename_bpm
189
+
190
+ # Fall back to detected BPM
191
+ if detected_bpm is not None:
192
+ return detected_bpm
193
+
194
+ raise BPMDetectionError(f"Could not determine BPM for {file_path}")
@@ -0,0 +1,206 @@
1
+ """Pipeline orchestration for processing drum breaks."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from .converter import convert_audio
8
+ from .detector import get_source_bpm
9
+ from .stretcher import stretch_to_bpm
10
+
11
+ # Supported audio extensions
12
+ SUPPORTED_EXTENSIONS = {".wav", ".flac"}
13
+
14
+
15
+ def _noop_echo(_: str) -> None:
16
+ """No-op function for echo when none is provided."""
17
+ pass
18
+
19
+
20
+ @dataclass
21
+ class ProcessingOptions:
22
+ """Options for audio processing."""
23
+
24
+ manual_bpm: float | None = None
25
+ sample_rate: int | None = None
26
+ bit_depth: int | None = None
27
+ mono: bool = False
28
+ warn: bool = False
29
+ crispness: int = 5
30
+
31
+
32
+ def is_audio_file(path: Path) -> bool:
33
+ """Check if a path is a supported audio file."""
34
+ return path.is_file() and path.suffix.lower() in SUPPORTED_EXTENSIONS
35
+
36
+
37
+ def generate_output_path(
38
+ input_path: Path,
39
+ output_dir: Path,
40
+ target_bpm: float,
41
+ ) -> Path:
42
+ """
43
+ Generate output path following the naming convention.
44
+
45
+ Structure: output_dir/{filename}/{filename}_{target_bpm}bpm.{ext}
46
+ """
47
+ stem = input_path.stem
48
+ ext = input_path.suffix
49
+ folder = output_dir / stem
50
+ filename = f"{stem}_{int(target_bpm)}bpm{ext}"
51
+ return folder / filename
52
+
53
+
54
+ def process_file(
55
+ input_path: Path,
56
+ targets: list[float],
57
+ output_dir: Path,
58
+ options: ProcessingOptions,
59
+ echo: Callable[[str], None] | None = None,
60
+ ) -> list[Path]:
61
+ """
62
+ Process a single audio file to multiple target BPMs.
63
+
64
+ Args:
65
+ input_path: Path to input audio file
66
+ targets: List of target BPMs
67
+ output_dir: Base output directory
68
+ options: Processing options
69
+ echo: Optional callback for status messages
70
+
71
+ Returns:
72
+ List of created output file paths
73
+ """
74
+ if echo is None:
75
+ echo = _noop_echo
76
+
77
+ # Detect source BPM
78
+ echo(f"Detecting BPM for {input_path.name}...")
79
+ source_bpm = get_source_bpm(
80
+ input_path,
81
+ manual_bpm=options.manual_bpm,
82
+ warn=options.warn,
83
+ warn_callback=echo,
84
+ )
85
+ echo(f" Source BPM: {source_bpm}")
86
+
87
+ output_paths = []
88
+
89
+ for target_bpm in targets:
90
+ output_path = generate_output_path(input_path, output_dir, target_bpm)
91
+ echo(f" Stretching to {int(target_bpm)} BPM -> {output_path}")
92
+
93
+ # Time stretch
94
+ stretch_to_bpm(
95
+ input_path,
96
+ output_path,
97
+ source_bpm,
98
+ target_bpm,
99
+ crispness=options.crispness,
100
+ )
101
+
102
+ # Apply format conversion if any options specified
103
+ if options.sample_rate or options.bit_depth or options.mono:
104
+ convert_audio(
105
+ output_path,
106
+ output_path,
107
+ sample_rate=options.sample_rate,
108
+ bit_depth=options.bit_depth,
109
+ mono=options.mono,
110
+ )
111
+
112
+ output_paths.append(output_path)
113
+
114
+ return output_paths
115
+
116
+
117
+ def process_directory(
118
+ input_dir: Path,
119
+ targets: list[float],
120
+ output_dir: Path,
121
+ options: ProcessingOptions,
122
+ echo: Callable[[str], None] | None = None,
123
+ ) -> list[Path]:
124
+ """
125
+ Process all audio files in a directory.
126
+
127
+ Args:
128
+ input_dir: Directory containing audio files
129
+ targets: List of target BPMs
130
+ output_dir: Base output directory
131
+ options: Processing options
132
+ echo: Optional callback for status messages
133
+
134
+ Returns:
135
+ List of all created output file paths
136
+ """
137
+ if echo is None:
138
+ echo = _noop_echo
139
+
140
+ # Find all audio files
141
+ audio_files = sorted([f for f in input_dir.iterdir() if is_audio_file(f)])
142
+
143
+ if not audio_files:
144
+ raise ValueError(f"No audio files found in {input_dir}")
145
+
146
+ echo(f"Found {len(audio_files)} audio file(s)")
147
+
148
+ all_outputs = []
149
+
150
+ for audio_file in audio_files:
151
+ outputs = process_file(audio_file, targets, output_dir, options, echo)
152
+ all_outputs.extend(outputs)
153
+
154
+ return all_outputs
155
+
156
+
157
+ def parse_targets(
158
+ target: float | None,
159
+ targets: str | None,
160
+ range_spec: str | None,
161
+ step: int,
162
+ ) -> list[float]:
163
+ """
164
+ Parse target BPM specifications into a list of BPMs.
165
+
166
+ Args:
167
+ target: Single target BPM
168
+ targets: Comma-separated target BPMs
169
+ range_spec: Range specification (e.g., "80-160")
170
+ step: Step size for range
171
+
172
+ Returns:
173
+ List of target BPMs
174
+
175
+ Raises:
176
+ ValueError: If no valid targets specified
177
+ """
178
+ result = []
179
+
180
+ if target is not None:
181
+ result.append(target)
182
+
183
+ if targets is not None:
184
+ for t in targets.split(","):
185
+ result.append(float(t.strip()))
186
+
187
+ if range_spec is not None:
188
+ parts = range_spec.split("-")
189
+ if len(parts) != 2:
190
+ raise ValueError(f"Invalid range format: {range_spec}. Use 'start-end'.")
191
+ start = int(parts[0])
192
+ end = int(parts[1])
193
+ result.extend(float(bpm) for bpm in range(start, end + 1, step))
194
+
195
+ if not result:
196
+ raise ValueError("No target BPM specified. Use --target, --targets, or --range.")
197
+
198
+ # Remove duplicates while preserving order
199
+ seen = set()
200
+ unique = []
201
+ for bpm in result:
202
+ if bpm not in seen:
203
+ seen.add(bpm)
204
+ unique.append(bpm)
205
+
206
+ return unique
@@ -0,0 +1,116 @@
1
+ """Time-stretching audio using rubberband."""
2
+
3
+ import shutil
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import pyrubberband
8
+ import soundfile as sf
9
+
10
+
11
+ class RubberbandNotFoundError(Exception):
12
+ """Raised when rubberband CLI is not installed."""
13
+
14
+ pass
15
+
16
+
17
+ def check_rubberband_installed() -> None:
18
+ """
19
+ Verify that rubberband CLI is installed and accessible.
20
+
21
+ Raises:
22
+ RubberbandNotFoundError: If rubberband is not found with install instructions.
23
+ """
24
+ if shutil.which("rubberband") is None:
25
+ platform = sys.platform
26
+ if platform == "darwin":
27
+ install_cmd = "brew install rubberband"
28
+ elif platform.startswith("linux"):
29
+ install_cmd = "sudo apt-get install rubberband-cli"
30
+ elif platform == "win32":
31
+ install_cmd = "Download from https://breakfastquay.com/rubberband/"
32
+ else:
33
+ install_cmd = "See https://breakfastquay.com/rubberband/"
34
+
35
+ raise RubberbandNotFoundError(
36
+ f"rubberband CLI not found. Install it with:\n {install_cmd}"
37
+ )
38
+
39
+
40
+ def calculate_stretch_ratio(source_bpm: float, target_bpm: float) -> float:
41
+ """
42
+ Calculate the time stretch ratio to convert from source to target BPM.
43
+
44
+ Args:
45
+ source_bpm: Original tempo in BPM
46
+ target_bpm: Desired tempo in BPM
47
+
48
+ Returns:
49
+ Stretch ratio for pyrubberband (>1 means faster/shorter, <1 means slower/longer)
50
+ """
51
+ # pyrubberband rate: >1 = faster (shorter duration), <1 = slower (longer duration)
52
+ # To go from 170 BPM to 85 BPM (slower), we need rate = 85/170 = 0.5
53
+ # To go from 85 BPM to 170 BPM (faster), we need rate = 170/85 = 2.0
54
+ return target_bpm / source_bpm
55
+
56
+
57
+ def stretch_audio(
58
+ input_path: Path,
59
+ output_path: Path,
60
+ ratio: float,
61
+ crispness: int = 5,
62
+ ) -> None:
63
+ """
64
+ Time-stretch audio file using rubberband.
65
+
66
+ Args:
67
+ input_path: Path to input audio file
68
+ output_path: Path for output audio file
69
+ ratio: Time stretch ratio (>1 = slower, <1 = faster)
70
+ crispness: Rubberband crispness setting (0-6, default 5 for drums)
71
+ Higher values preserve transients better.
72
+ """
73
+ # Load audio
74
+ y, sr = sf.read(input_path)
75
+
76
+ # pyrubberband expects mono or stereo as (samples,) or (samples, channels)
77
+ # soundfile returns (samples, channels) for stereo, (samples,) for mono
78
+
79
+ # Map crispness to pyrubberband options
80
+ # Crispness 5 = "--crispness 5" which is good for drums
81
+ # pyrubberband uses rbargs as a dict
82
+ rbargs = {"--crispness": str(crispness)}
83
+
84
+ # Time stretch
85
+ y_stretched = pyrubberband.time_stretch(y, sr, ratio, rbargs=rbargs)
86
+
87
+ # Ensure output directory exists
88
+ output_path.parent.mkdir(parents=True, exist_ok=True)
89
+
90
+ # Determine subtype based on input file format
91
+ input_info = sf.info(input_path)
92
+ subtype = input_info.subtype
93
+
94
+ # Write output with same format as input
95
+ sf.write(output_path, y_stretched, sr, subtype=subtype)
96
+
97
+
98
+ def stretch_to_bpm(
99
+ input_path: Path,
100
+ output_path: Path,
101
+ source_bpm: float,
102
+ target_bpm: float,
103
+ crispness: int = 5,
104
+ ) -> None:
105
+ """
106
+ Convenience function to stretch audio from source BPM to target BPM.
107
+
108
+ Args:
109
+ input_path: Path to input audio file
110
+ output_path: Path for output audio file
111
+ source_bpm: Original tempo in BPM
112
+ target_bpm: Desired tempo in BPM
113
+ crispness: Rubberband crispness setting (0-6, default 5 for drums)
114
+ """
115
+ ratio = calculate_stretch_ratio(source_bpm, target_bpm)
116
+ stretch_audio(input_path, output_path, ratio, crispness)
@@ -0,0 +1,344 @@
1
+ Metadata-Version: 2.4
2
+ Name: breaks-machine
3
+ Version: 0.1.0
4
+ Summary: Time-stretch drum breaks to target BPMs with automatic tempo detection
5
+ Requires-Python: >=3.13
6
+ Requires-Dist: click>=8.3.1
7
+ Requires-Dist: librosa>=0.11.0
8
+ Requires-Dist: pyrubberband>=0.4.0
9
+ Requires-Dist: soundfile>=0.13.1
10
+ Description-Content-Type: text/markdown
11
+
12
+ # breaks-machine
13
+
14
+ Time-stretch drum breaks to target BPMs while preserving transient quality.
15
+
16
+ Perfect for preparing drum breaks for hardware samplers, live performance, or production workflows where you need breaks at specific tempos.
17
+
18
+ ## Features
19
+
20
+ - **Automatic BPM Detection**: Intelligently detects tempo from filename patterns or uses librosa for audio analysis
21
+ - **Multiple Target Modes**: Stretch to single BPM, multiple targets, or ranges with custom steps
22
+ - **Batch Processing**: Process entire directories of breaks in one command
23
+ - **High-Quality Stretching**: Uses Rubberband with crispness=5 optimized for transient preservation
24
+ - **Format Conversion**: Optional sample rate, bit depth, and channel conversion
25
+ - **Smart Detection**: Multi-strategy BPM detection with subdivision correction for complex breakbeats
26
+
27
+ ## Requirements
28
+
29
+ ### System Dependencies
30
+
31
+ **Rubberband** is required for time-stretching:
32
+
33
+ **macOS:**
34
+ ```bash
35
+ brew install rubberband
36
+ ```
37
+
38
+ **Ubuntu/Debian:**
39
+ ```bash
40
+ sudo apt-get install rubberband-cli libsndfile1 ffmpeg
41
+ ```
42
+
43
+ **Windows:**
44
+ Download Rubberband from [https://breakfastquay.com/rubberband/](https://breakfastquay.com/rubberband/) and add to PATH.
45
+
46
+ ### Python
47
+
48
+ Python 3.13+ required.
49
+
50
+ ## Installation
51
+
52
+ **With uv (recommended):**
53
+ ```bash
54
+ uv tool install breaks-machine
55
+ ```
56
+
57
+ **With pip:**
58
+ ```bash
59
+ pip install breaks-machine
60
+ ```
61
+
62
+ **From source:**
63
+ ```bash
64
+ git clone https://github.com/yourusername/breaks-machine.git
65
+ cd breaks-machine
66
+ uv sync
67
+ ```
68
+
69
+ ## Quick Start
70
+
71
+ ### Single File, Single Target
72
+
73
+ Stretch a break to 140 BPM:
74
+ ```bash
75
+ breaks-machine stretch amen_170.wav --target 140
76
+ ```
77
+
78
+ Output: `output/amen_170/amen_170_140bpm.wav`
79
+
80
+ ### Multiple Targets
81
+
82
+ Create versions at different BPMs:
83
+ ```bash
84
+ breaks-machine stretch amen_170.wav --targets 90,120,140,160
85
+ ```
86
+
87
+ Output:
88
+ ```
89
+ output/amen_170/
90
+ ├── amen_170_90bpm.wav
91
+ ├── amen_170_120bpm.wav
92
+ ├── amen_170_140bpm.wav
93
+ └── amen_170_160bpm.wav
94
+ ```
95
+
96
+ ### BPM Range
97
+
98
+ Generate versions across a range:
99
+ ```bash
100
+ breaks-machine stretch break.wav --range 80-160 --step 10
101
+ ```
102
+
103
+ Creates versions at 80, 90, 100, ..., 160 BPM.
104
+
105
+ ### Batch Processing
106
+
107
+ Process an entire directory:
108
+ ```bash
109
+ breaks-machine stretch ./breaks/ --target 140 --output ./processed/
110
+ ```
111
+
112
+ ### With Manual BPM Override
113
+
114
+ If auto-detection fails or you know the correct tempo:
115
+ ```bash
116
+ breaks-machine stretch break.wav --bpm 175 --target 140
117
+ ```
118
+
119
+ ## CLI Reference
120
+
121
+ ```
122
+ breaks-machine stretch INPUT_PATH [OPTIONS]
123
+ ```
124
+
125
+ ### Arguments
126
+
127
+ - `INPUT_PATH`: Path to audio file (.wav, .flac) or directory containing audio files
128
+
129
+ ### Options
130
+
131
+ **Target Specification** (required, choose one):
132
+ - `-t, --target BPM`: Single target BPM
133
+ - `--targets BPM,BPM,...`: Comma-separated target BPMs
134
+ - `-r, --range START-END`: BPM range with optional step
135
+
136
+ **BPM Detection**:
137
+ - `-b, --bpm BPM`: Manual source BPM override
138
+ - `-w, --warn`: Warn if detected BPM differs from filename
139
+
140
+ **Output**:
141
+ - `-o, --output DIR`: Output directory (default: `./output`)
142
+
143
+ **Format Conversion**:
144
+ - `--sample-rate HZ`: Target sample rate (e.g., 44100, 48000)
145
+ - `--bit-depth {16,24}`: Target bit depth
146
+ - `--mono`: Convert to mono
147
+
148
+ **Stretching**:
149
+ - `--crispness {0-6}`: Rubberband crispness (default: 5, higher preserves transients)
150
+ - `-s, --step N`: Step size for range mode (default: 10)
151
+
152
+ ### Examples
153
+
154
+ **Basic usage**:
155
+ ```bash
156
+ # Stretch to single target
157
+ breaks-machine stretch amen_170.wav -t 140
158
+
159
+ # Multiple targets
160
+ breaks-machine stretch break.wav --targets 90,120,140
161
+
162
+ # Range with custom step
163
+ breaks-machine stretch break.wav --range 100-140 --step 5
164
+ ```
165
+
166
+ **With format conversion**:
167
+ ```bash
168
+ # Convert to 44.1kHz mono 16-bit
169
+ breaks-machine stretch break.wav -t 140 --sample-rate 44100 --bit-depth 16 --mono
170
+ ```
171
+
172
+ **Batch processing**:
173
+ ```bash
174
+ # Process directory
175
+ breaks-machine stretch ./breaks/ -t 140 -o ./output/
176
+
177
+ # With manual BPM for all files
178
+ breaks-machine stretch ./breaks/ -t 140 --bpm 170
179
+ ```
180
+
181
+ ## How It Works
182
+
183
+ ### Architecture
184
+
185
+ ```
186
+ Input Audio → BPM Detection → Time Stretching → Format Conversion → Output
187
+ ↓ ↓ ↓
188
+ detector.py stretcher.py converter.py
189
+ ```
190
+
191
+ ### BPM Detection Priority
192
+
193
+ 1. **Manual Override** (`--bpm`): If specified, uses this value
194
+ 2. **Filename Parsing**: Looks for patterns like `amen_170.wav`, `break-140bpm.flac`
195
+ 3. **Auto-Detection**: Uses librosa with multi-strategy detection:
196
+ - Tries multiple tempo priors (120, 140, 170 BPM)
197
+ - Applies subdivision correction for complex breakbeats
198
+ - Prefers direct detections over derived subdivisions
199
+ - Biases toward common breakbeat range (140-180 BPM)
200
+
201
+ ### Rubberband Crispness
202
+
203
+ The tool uses crispness=5 by default, which:
204
+ - Preserves transients (drum hits)
205
+ - Minimizes phase artifacts
206
+ - Optimized for percussive material
207
+
208
+ You can adjust with `--crispness {0-6}` (higher = more transient preservation).
209
+
210
+ ## Supported Formats
211
+
212
+ - **Input**: WAV, FLAC
213
+ - **Output**: Same format as input (or specify conversion options)
214
+
215
+ ## Development
216
+
217
+ ### Local Setup
218
+
219
+ 1. **Clone the repository**:
220
+ ```bash
221
+ git clone https://github.com/yourusername/breaks-machine.git
222
+ cd breaks-machine
223
+ ```
224
+
225
+ 2. **Install system dependencies** (see Requirements above)
226
+
227
+ 3. **Install Python dependencies**:
228
+ ```bash
229
+ uv sync --group dev
230
+ ```
231
+
232
+ 4. **Run tests**:
233
+ ```bash
234
+ uv run pytest tests
235
+ ```
236
+
237
+ ### Using Devcontainer (Optional)
238
+
239
+ For zero-friction setup with all dependencies pre-installed:
240
+
241
+ 1. **Install prerequisites**:
242
+ - [Docker Desktop](https://www.docker.com/products/docker-desktop/)
243
+ - [VS Code](https://code.visualstudio.com/) with [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
244
+
245
+ 2. **Copy devcontainer template**:
246
+ ```bash
247
+ cp -r .devcontainer-template .devcontainer
248
+ ```
249
+
250
+ 3. **Open in container**:
251
+ - VS Code → Command Palette → "Dev Containers: Reopen in Container"
252
+ - Wait for build (first time takes a few minutes)
253
+
254
+ See [.devcontainer-template/README.md](.devcontainer-template/README.md) for details.
255
+
256
+ ### Running Tests
257
+
258
+ ```bash
259
+ # All tests
260
+ uv run pytest tests
261
+
262
+ # With coverage
263
+ uv run pytest tests --cov=src/breaks_machine
264
+
265
+ # Specific test file
266
+ uv run pytest tests/test_detector.py -v
267
+ ```
268
+
269
+ ### Code Quality
270
+
271
+ ```bash
272
+ # Format code
273
+ uvx ruff format
274
+
275
+ # Lint code
276
+ uvx ruff check --fix
277
+
278
+ # Type checking (if added)
279
+ uvx mypy src/
280
+ ```
281
+
282
+ ### Manual Testing
283
+
284
+ The repository includes test breaks in the `breaks/` directory:
285
+ ```bash
286
+ # Test with real breaks
287
+ uv run breaks-machine stretch breaks/FR_Drum_Loop_160.wav -t 140
288
+ ```
289
+
290
+ ### Project Structure
291
+
292
+ ```
293
+ src/breaks_machine/
294
+ ├── cli.py # Click-based CLI entry point
295
+ ├── detector.py # BPM detection (filename + librosa)
296
+ ├── stretcher.py # Rubberband wrapper
297
+ ├── converter.py # Format conversion
298
+ └── processor.py # Processing pipeline
299
+
300
+ tests/
301
+ ├── test_cli.py
302
+ ├── test_detector.py
303
+ ├── test_stretcher.py
304
+ ├── test_converter.py
305
+ └── test_processor.py
306
+ ```
307
+
308
+ ## Technical Details
309
+
310
+ ### Dependencies
311
+
312
+ - **click**: CLI framework
313
+ - **librosa**: Audio analysis and BPM detection
314
+ - **pyrubberband**: Python wrapper for Rubberband
315
+ - **soundfile**: Audio I/O for WAV/FLAC
316
+
317
+ ### System Requirements
318
+
319
+ - **rubberband-cli**: Time-stretching engine
320
+ - **libsndfile1**: Audio file I/O library
321
+ - **ffmpeg**: Audio codec support
322
+
323
+ See the Requirements section for platform-specific installation instructions.
324
+
325
+ ## Contributing
326
+
327
+ Contributions are welcome! Please:
328
+
329
+ 1. Fork the repository
330
+ 2. Create a feature branch
331
+ 3. Make your changes
332
+ 4. Run tests: `uv run pytest tests`
333
+ 5. Format code: `uvx ruff format`
334
+ 6. Submit a pull request
335
+
336
+ ## License
337
+
338
+ [Add your license here]
339
+
340
+ ## Acknowledgments
341
+
342
+ - **Rubberband**: Industry-standard time-stretching library
343
+ - **librosa**: Audio analysis toolkit
344
+ - Built with modern Python tooling (uv, ruff, pytest)
@@ -0,0 +1,10 @@
1
+ breaks_machine/__init__.py,sha256=gHQTrdDrMhdLOz9EkSLbI049ROXV31KxtlWtXyFdlLE,53
2
+ breaks_machine/cli.py,sha256=Qttj-QnIWtKksEP2M0Bn20-_py1DFrATABHXfRNkNtg,4301
3
+ breaks_machine/converter.py,sha256=o6XfA1_hftoD6OrMlt-fGg9eBec7RbnUaJq_KGOKPLA,2497
4
+ breaks_machine/detector.py,sha256=uiRabYUWkyJRXEfNQmTJw3n_lNF2X9nmJNI1tvpvAxQ,6281
5
+ breaks_machine/processor.py,sha256=ZPA3IzYZrUEbNc4g33V9aq0Fo_Pbi45i5r8u2V72fdw,5299
6
+ breaks_machine/stretcher.py,sha256=UFuNr2888INDcrONre21iQLekwGX4xrGcx5STPu_v-w,3609
7
+ breaks_machine-0.1.0.dist-info/METADATA,sha256=2UcQaZxICsHTvPmRAdSXrV2WNz4p1A-5OkisUglwkFQ,8244
8
+ breaks_machine-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
9
+ breaks_machine-0.1.0.dist-info/entry_points.txt,sha256=eNWJkeqXBEWh794-GFt12qIGag77LaMM1FQ8jqfvZak,59
10
+ breaks_machine-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ breaks-machine = breaks_machine.cli:main