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.
- breaks_machine/__init__.py +3 -0
- breaks_machine/cli.py +190 -0
- breaks_machine/converter.py +95 -0
- breaks_machine/detector.py +194 -0
- breaks_machine/processor.py +206 -0
- breaks_machine/stretcher.py +116 -0
- breaks_machine-0.1.0.dist-info/METADATA +344 -0
- breaks_machine-0.1.0.dist-info/RECORD +10 -0
- breaks_machine-0.1.0.dist-info/WHEEL +4 -0
- breaks_machine-0.1.0.dist-info/entry_points.txt +2 -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,,
|