feat: drumloop-first generation with forensic analysis
- Add DrumLoopAnalyzer: extracts BPM, transients, key, beat grid from drumloops - Rewrite compose.py: drumloop drives everything (BPM, key, rhythm) - Bass tresillo pattern placed in kick-free zones - Chords change on downbeats matching drumloop key - Melody avoids transients, emphasizes chord tones - Vocal chops between transients, clap on dembow (beats 2, 3.5) - Remove COLOR token (not recognized by REAPER) - 90 tests passing, generates drumloop_song.rpp with 10 tracks, 20 plugins
This commit is contained in:
2240
output/drumloop_analysis.json
Normal file
2240
output/drumloop_analysis.json
Normal file
File diff suppressed because it is too large
Load Diff
2598
output/drumloop_song.rpp
Normal file
2598
output/drumloop_song.rpp
Normal file
File diff suppressed because it is too large
Load Diff
153
scripts/analyze_drumloop.py
Normal file
153
scripts/analyze_drumloop.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""Analyze drumloops from the library and output structured forensic data.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/analyze_drumloop.py [--count N] [--output PATH] [--json PATH]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(PROJECT))
|
||||||
|
|
||||||
|
from src.composer.drum_analyzer import DrumLoopAnalyzer
|
||||||
|
|
||||||
|
|
||||||
|
def load_drumloop_paths(index_path: Path, count: int = 5) -> list[dict]:
|
||||||
|
with open(index_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
drumloops = [s for s in data["samples"] if s.get("role") == "drumloop"]
|
||||||
|
|
||||||
|
scored = []
|
||||||
|
for d in drumloops:
|
||||||
|
path = Path(d["original_path"])
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
dur = d.get("signal", {}).get("duration", 0)
|
||||||
|
onsets = d.get("perceptual", {}).get("onset_count", 0)
|
||||||
|
density = onsets / max(dur, 0.01)
|
||||||
|
tempo = d.get("perceptual", {}).get("tempo", 0)
|
||||||
|
if 85 <= tempo <= 150 and 6 <= dur <= 35:
|
||||||
|
scored.append((d, density))
|
||||||
|
|
||||||
|
scored.sort(key=lambda x: abs(x[1] - 4.0))
|
||||||
|
return [s[0] for s in scored[:count]]
|
||||||
|
|
||||||
|
|
||||||
|
def print_analysis(result_dict: dict) -> None:
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print(f" {Path(result_dict['file_path']).name}")
|
||||||
|
print(f"{'='*70}")
|
||||||
|
print(f" BPM: {result_dict['bpm']}")
|
||||||
|
print(f" Duration: {result_dict['duration']:.2f}s")
|
||||||
|
print(f" Bars: {result_dict['bar_count']}")
|
||||||
|
print(f" Key: {result_dict['key']} (conf: {result_dict['key_confidence']:.2f})")
|
||||||
|
print(f" Beat grid: {len(result_dict['beat_grid']['quarter'])} quarters, "
|
||||||
|
f"{len(result_dict['beat_grid']['eighth'])} eighths, "
|
||||||
|
f"{len(result_dict['beat_grid']['sixteenth'])} sixteenths")
|
||||||
|
|
||||||
|
summary = result_dict["summary"]
|
||||||
|
total = summary["kick_count"] + summary["snare_count"] + summary["hihat_count"] + summary["other_count"]
|
||||||
|
print(f"\n Transients: {total} total")
|
||||||
|
print(f" Kicks: {summary['kick_count']}")
|
||||||
|
print(f" Snares: {summary['snare_count']}")
|
||||||
|
print(f" HiHats: {summary['hihat_count']}")
|
||||||
|
print(f" Other: {summary['other_count']}")
|
||||||
|
|
||||||
|
transients_by_type = {}
|
||||||
|
for t in result_dict["transients"]:
|
||||||
|
transients_by_type.setdefault(t["type"], []).append(t)
|
||||||
|
|
||||||
|
for ttype in ["kick", "snare", "hihat", "other"]:
|
||||||
|
ts = transients_by_type.get(ttype, [])
|
||||||
|
if not ts:
|
||||||
|
continue
|
||||||
|
print(f"\n {ttype.upper()} positions (beat positions):")
|
||||||
|
positions = [f"{t['beat_pos']:.2f}" for t in ts[:20]]
|
||||||
|
line = " " + " ".join(positions)
|
||||||
|
if len(ts) > 20:
|
||||||
|
line += f" ... +{len(ts)-20} more"
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
if result_dict["energy_profile"]:
|
||||||
|
print(f"\n Energy profile (first 16 beats):")
|
||||||
|
bars_e = result_dict["energy_profile"][:16]
|
||||||
|
max_e = max(bars_e) if bars_e else 1
|
||||||
|
for i, e in enumerate(bars_e):
|
||||||
|
bar = i // 4
|
||||||
|
beat = i % 4
|
||||||
|
filled = int((e / max_e) * 30) if max_e > 0 else 0
|
||||||
|
print(f" Bar {bar+1} Beat {beat+1}: {'|' * filled} ({e:.4f})")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Analyze drumloops forensically")
|
||||||
|
parser.add_argument("--count", type=int, default=3, help="Number of drumloops to analyze")
|
||||||
|
parser.add_argument("--index", type=str, default=None, help="Path to sample_index.json")
|
||||||
|
parser.add_argument("--file", type=str, default=None, help="Analyze a single file instead")
|
||||||
|
parser.add_argument("--json", type=str, default=None, help="Save results as JSON")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
index_path = Path(args.index) if args.index else PROJECT / "data" / "sample_index.json"
|
||||||
|
results = []
|
||||||
|
|
||||||
|
if args.file:
|
||||||
|
print(f"Analyzing: {args.file}")
|
||||||
|
analyzer = DrumLoopAnalyzer(args.file)
|
||||||
|
result = analyzer.analyze()
|
||||||
|
results.append(result.to_dict())
|
||||||
|
print_analysis(results[0])
|
||||||
|
else:
|
||||||
|
drumloops = load_drumloop_paths(index_path, args.count)
|
||||||
|
if not drumloops:
|
||||||
|
print("No suitable drumloops found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Selected {len(drumloops)} drumloops for analysis:\n")
|
||||||
|
for d in drumloops:
|
||||||
|
print(f" - {d['original_name']} "
|
||||||
|
f"(tempo={d.get('perceptual',{}).get('tempo','?')}, "
|
||||||
|
f"dur={d.get('signal',{}).get('duration',0):.1f}s)")
|
||||||
|
|
||||||
|
for d in drumloops:
|
||||||
|
path = d["original_path"]
|
||||||
|
print(f"\nAnalyzing: {Path(path).name}...")
|
||||||
|
analyzer = DrumLoopAnalyzer(path)
|
||||||
|
result = analyzer.analyze()
|
||||||
|
results.append(result.to_dict())
|
||||||
|
print_analysis(result.to_dict())
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
out_path = Path(args.json)
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(out_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(results, f, indent=2, ensure_ascii=False)
|
||||||
|
print(f"\nResults saved to: {out_path}")
|
||||||
|
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print("DRUMLOOP-FIRST GENERATION APPROACH")
|
||||||
|
print(f"{'='*70}")
|
||||||
|
print("""
|
||||||
|
1. SELECT drumloop -> extract BPM + beat grid + transient map
|
||||||
|
2. ALIGN project -> set REAPER tempo to drumloop BPM
|
||||||
|
3. GENERATE bass -> tresillo pattern in kick-free zones
|
||||||
|
- Reggaeton tresillo: notes at 0.0, 0.75, 1.5, 2.0, 2.75, 3.5
|
||||||
|
- Place bass between kick transients (margin +/-0.15 beats)
|
||||||
|
4. GENERATE chords -> change on downbeats (beat 1 of each bar)
|
||||||
|
- Sustain through bar, use i-VI-III-VII progression
|
||||||
|
- Match key from drumloop analysis
|
||||||
|
5. GENERATE melody -> place on transient-free zones
|
||||||
|
- Emphasize chord tones on strong beats
|
||||||
|
- Syncopation matches dembow feel
|
||||||
|
6. GENERATE vocals -> chops in gaps between drum transients
|
||||||
|
7. SELECT samples -> match drumloop key for compatible tonal samples
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
File diff suppressed because it is too large
Load Diff
336
src/composer/drum_analyzer.py
Normal file
336
src/composer/drum_analyzer.py
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
"""DrumLoop-first forensic analyzer for reggaeton production.
|
||||||
|
|
||||||
|
Analyzes a drumloop WAV file and extracts:
|
||||||
|
- BPM and beat grid (quarter, eighth, sixteenth note positions)
|
||||||
|
- Transient positions with classification (kick / snare / hihat / other)
|
||||||
|
- Energy envelope per beat
|
||||||
|
- Musical key (if detectable)
|
||||||
|
|
||||||
|
The analysis result drives all other generation: bass, chords, melody,
|
||||||
|
and vocals are aligned to the drumloop's rhythmic skeleton.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from src.composer.drum_analyzer import DrumLoopAnalyzer
|
||||||
|
|
||||||
|
analyzer = DrumLoopAnalyzer("path/to/drumloop.wav")
|
||||||
|
result = analyzer.analyze()
|
||||||
|
print(f"BPM: {result.bpm}, Transients: {len(result.transients)}")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import librosa
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
|
||||||
|
|
||||||
|
KEY_PROFILES = {
|
||||||
|
name: np.array(profile)
|
||||||
|
for name, profile in {
|
||||||
|
"major": [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88],
|
||||||
|
"minor": [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17],
|
||||||
|
}.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Transient:
|
||||||
|
time: float
|
||||||
|
type: str # "kick" | "snare" | "hihat" | "other"
|
||||||
|
energy: float
|
||||||
|
spectral_centroid: float
|
||||||
|
confidence: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BeatGrid:
|
||||||
|
quarter: list[float] = field(default_factory=list)
|
||||||
|
eighth: list[float] = field(default_factory=list)
|
||||||
|
sixteenth: list[float] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DrumLoopAnalysis:
|
||||||
|
file_path: str
|
||||||
|
bpm: float
|
||||||
|
duration: float
|
||||||
|
beats: list[float] = field(default_factory=list)
|
||||||
|
transients: list[Transient] = field(default_factory=list)
|
||||||
|
beat_grid: BeatGrid = field(default_factory=BeatGrid)
|
||||||
|
key: Optional[str] = None
|
||||||
|
key_confidence: float = 0.0
|
||||||
|
energy_profile: list[float] = field(default_factory=list)
|
||||||
|
bar_count: int = 0
|
||||||
|
sample_rate: int = 44100
|
||||||
|
|
||||||
|
def transients_of_type(self, ttype: str) -> list[Transient]:
|
||||||
|
return [t for t in self.transients if t.type == ttype]
|
||||||
|
|
||||||
|
def transient_positions(self, ttype: Optional[str] = None) -> list[float]:
|
||||||
|
ts = self.transients if ttype is None else self.transients_of_type(ttype)
|
||||||
|
return [t.time for t in ts]
|
||||||
|
|
||||||
|
def kick_free_zones(self, margin_beats: float = 0.25) -> list[tuple[float, float]]:
|
||||||
|
kicks = sorted(self.transient_positions("kick"))
|
||||||
|
beat_dur = 60.0 / self.bpm
|
||||||
|
margin_sec = margin_beats * beat_dur
|
||||||
|
zones = []
|
||||||
|
prev = 0.0
|
||||||
|
for k in kicks:
|
||||||
|
start = prev
|
||||||
|
end = k - margin_sec
|
||||||
|
if end > start:
|
||||||
|
zones.append((start, end))
|
||||||
|
prev = k + margin_sec
|
||||||
|
if prev < self.duration:
|
||||||
|
zones.append((prev, self.duration))
|
||||||
|
return zones
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"file_path": self.file_path,
|
||||||
|
"bpm": round(self.bpm, 2),
|
||||||
|
"duration": round(self.duration, 4),
|
||||||
|
"bar_count": self.bar_count,
|
||||||
|
"key": self.key,
|
||||||
|
"key_confidence": round(self.key_confidence, 4),
|
||||||
|
"sample_rate": self.sample_rate,
|
||||||
|
"beat_grid": {
|
||||||
|
"quarter": [round(b, 4) for b in self.beat_grid.quarter],
|
||||||
|
"eighth": [round(b, 4) for b in self.beat_grid.eighth],
|
||||||
|
"sixteenth": [round(b, 4) for b in self.beat_grid.sixteenth],
|
||||||
|
},
|
||||||
|
"transients": [
|
||||||
|
{
|
||||||
|
"time": round(t.time, 4),
|
||||||
|
"beat_pos": round(t.time / (60.0 / self.bpm), 4) if self.bpm > 0 else 0.0,
|
||||||
|
"type": t.type,
|
||||||
|
"energy": round(t.energy, 4),
|
||||||
|
"spectral_centroid": round(t.spectral_centroid, 1),
|
||||||
|
"confidence": round(t.confidence, 4),
|
||||||
|
}
|
||||||
|
for t in self.transients
|
||||||
|
],
|
||||||
|
"energy_profile": [round(e, 4) for e in self.energy_profile],
|
||||||
|
"summary": {
|
||||||
|
"kick_count": len(self.transients_of_type("kick")),
|
||||||
|
"snare_count": len(self.transients_of_type("snare")),
|
||||||
|
"hihat_count": len(self.transients_of_type("hihat")),
|
||||||
|
"other_count": len(self.transients_of_type("other")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DrumLoopAnalyzer:
|
||||||
|
def __init__(self, file_path: str | Path, sr: int = 44100):
|
||||||
|
self.file_path = str(file_path)
|
||||||
|
self.sr = sr
|
||||||
|
self._y: Optional[np.ndarray] = None
|
||||||
|
self._sr_actual: int = sr
|
||||||
|
|
||||||
|
def _load(self) -> tuple[np.ndarray, int]:
|
||||||
|
if self._y is None:
|
||||||
|
y, sr = librosa.load(self.file_path, sr=self.sr, mono=True)
|
||||||
|
self._y = y
|
||||||
|
self._sr_actual = sr
|
||||||
|
return self._y, self._sr_actual
|
||||||
|
|
||||||
|
def analyze(self) -> DrumLoopAnalysis:
|
||||||
|
y, sr = self._load()
|
||||||
|
duration = float(len(y) / sr)
|
||||||
|
|
||||||
|
bpm, beat_frames = self._detect_tempo_and_beats(y, sr)
|
||||||
|
beats = librosa.frames_to_time(beat_frames, sr=sr).tolist()
|
||||||
|
|
||||||
|
onset_env = librosa.onset.onset_strength(y=y, sr=sr)
|
||||||
|
onset_frames = librosa.onset.onset_detect(
|
||||||
|
y=y, sr=sr, onset_envelope=onset_env, backtrack=True
|
||||||
|
)
|
||||||
|
onset_times = librosa.frames_to_time(onset_frames, sr=sr)
|
||||||
|
|
||||||
|
transients = self._classify_transients(y, sr, onset_frames, onset_times)
|
||||||
|
|
||||||
|
beat_grid = self._build_beat_grid(beats, bpm, duration)
|
||||||
|
|
||||||
|
key, key_conf = self._detect_key(y, sr)
|
||||||
|
|
||||||
|
energy_profile = self._energy_per_beat(y, sr, beats)
|
||||||
|
|
||||||
|
bar_count = int(len(beats) // 4) if beats else int(duration / (240.0 / bpm))
|
||||||
|
|
||||||
|
return DrumLoopAnalysis(
|
||||||
|
file_path=self.file_path,
|
||||||
|
bpm=bpm,
|
||||||
|
duration=duration,
|
||||||
|
beats=beats,
|
||||||
|
transients=transients,
|
||||||
|
beat_grid=beat_grid,
|
||||||
|
key=key,
|
||||||
|
key_confidence=key_conf,
|
||||||
|
energy_profile=energy_profile,
|
||||||
|
bar_count=bar_count,
|
||||||
|
sample_rate=sr,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _detect_tempo_and_beats(self, y: np.ndarray, sr: int) -> tuple[float, np.ndarray]:
|
||||||
|
onset_env = librosa.onset.onset_strength(y=y, sr=sr)
|
||||||
|
tempo, beat_frames = librosa.beat.beat_track(
|
||||||
|
onset_envelope=onset_env, sr=sr, units="frames"
|
||||||
|
)
|
||||||
|
if isinstance(tempo, np.ndarray):
|
||||||
|
if tempo.ndim == 0:
|
||||||
|
bpm = float(tempo)
|
||||||
|
else:
|
||||||
|
bpm = float(tempo[0])
|
||||||
|
else:
|
||||||
|
bpm = float(tempo)
|
||||||
|
if bpm < 60:
|
||||||
|
bpm *= 2
|
||||||
|
elif bpm > 200:
|
||||||
|
bpm /= 2
|
||||||
|
return bpm, beat_frames
|
||||||
|
|
||||||
|
def _classify_transients(
|
||||||
|
self, y: np.ndarray, sr: int, onset_frames: np.ndarray, onset_times: np.ndarray
|
||||||
|
) -> list[Transient]:
|
||||||
|
if len(onset_frames) == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
hop_length = 512
|
||||||
|
S = np.abs(librosa.stft(y, hop_length=hop_length))
|
||||||
|
freqs = librosa.fft_frequencies(sr=sr)
|
||||||
|
|
||||||
|
low_mask = freqs < 200
|
||||||
|
mid_mask = (freqs >= 200) & (freqs < 5000)
|
||||||
|
high_mask = freqs >= 5000
|
||||||
|
|
||||||
|
transients = []
|
||||||
|
for i, frame in enumerate(onset_frames):
|
||||||
|
if frame >= S.shape[1]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
spectrum = S[:, frame]
|
||||||
|
low_e = float(np.sum(spectrum[low_mask] ** 2))
|
||||||
|
mid_e = float(np.sum(spectrum[mid_mask] ** 2))
|
||||||
|
high_e = float(np.sum(spectrum[high_mask] ** 2))
|
||||||
|
total_e = low_e + mid_e + high_e + 1e-10
|
||||||
|
|
||||||
|
centroid = float(librosa.feature.spectral_centroid(
|
||||||
|
S=S[:, max(0, frame - 1):frame + 2], sr=sr, hop_length=hop_length
|
||||||
|
).mean())
|
||||||
|
|
||||||
|
low_ratio = low_e / total_e
|
||||||
|
mid_ratio = mid_e / total_e
|
||||||
|
high_ratio = high_e / total_e
|
||||||
|
|
||||||
|
energy = float(np.sqrt(total_e))
|
||||||
|
|
||||||
|
if low_ratio > 0.55:
|
||||||
|
ttype = "kick"
|
||||||
|
conf = min(1.0, low_ratio / 0.7)
|
||||||
|
elif high_ratio > 0.35:
|
||||||
|
ttype = "hihat"
|
||||||
|
conf = min(1.0, high_ratio / 0.5)
|
||||||
|
elif mid_ratio > 0.40:
|
||||||
|
ttype = "snare"
|
||||||
|
conf = min(1.0, mid_ratio / 0.6)
|
||||||
|
else:
|
||||||
|
if low_ratio > mid_ratio and low_ratio > high_ratio:
|
||||||
|
ttype = "kick"
|
||||||
|
conf = max(low_ratio, 0.3)
|
||||||
|
elif high_ratio > mid_ratio:
|
||||||
|
ttype = "hihat"
|
||||||
|
conf = max(high_ratio, 0.3)
|
||||||
|
else:
|
||||||
|
ttype = "snare"
|
||||||
|
conf = max(mid_ratio, 0.3)
|
||||||
|
|
||||||
|
transients.append(Transient(
|
||||||
|
time=float(onset_times[i]),
|
||||||
|
type=ttype,
|
||||||
|
energy=energy,
|
||||||
|
spectral_centroid=centroid,
|
||||||
|
confidence=conf,
|
||||||
|
))
|
||||||
|
|
||||||
|
return transients
|
||||||
|
|
||||||
|
def _build_beat_grid(
|
||||||
|
self, beats: list[float], bpm: float, duration: float
|
||||||
|
) -> BeatGrid:
|
||||||
|
if not beats or bpm <= 0:
|
||||||
|
return BeatGrid()
|
||||||
|
|
||||||
|
beat_dur = 60.0 / bpm
|
||||||
|
eighth_dur = beat_dur / 2.0
|
||||||
|
sixteenth_dur = beat_dur / 4.0
|
||||||
|
|
||||||
|
start = beats[0]
|
||||||
|
all_quarter = []
|
||||||
|
all_eighth = []
|
||||||
|
all_sixteenth = []
|
||||||
|
|
||||||
|
t = start
|
||||||
|
while t < duration:
|
||||||
|
all_quarter.append(round(t, 4))
|
||||||
|
t += beat_dur
|
||||||
|
|
||||||
|
t = start
|
||||||
|
while t < duration:
|
||||||
|
all_eighth.append(round(t, 4))
|
||||||
|
t += eighth_dur
|
||||||
|
|
||||||
|
t = start
|
||||||
|
while t < duration:
|
||||||
|
all_sixteenth.append(round(t, 4))
|
||||||
|
t += sixteenth_dur
|
||||||
|
|
||||||
|
return BeatGrid(
|
||||||
|
quarter=all_quarter,
|
||||||
|
eighth=all_eighth,
|
||||||
|
sixteenth=all_sixteenth,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _detect_key(self, y: np.ndarray, sr: int) -> tuple[Optional[str], float]:
|
||||||
|
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
|
||||||
|
chroma_avg = np.mean(chroma, axis=1)
|
||||||
|
|
||||||
|
best_key = None
|
||||||
|
best_corr = -1.0
|
||||||
|
|
||||||
|
for mode_name, profile in KEY_PROFILES.items():
|
||||||
|
for shift in range(12):
|
||||||
|
rotated = np.roll(profile, shift)
|
||||||
|
corr = float(np.corrcoef(chroma_avg, rotated)[0, 1])
|
||||||
|
if corr > best_corr:
|
||||||
|
best_corr = corr
|
||||||
|
best_key = f"{NOTE_NAMES[shift]}{'m' if mode_name == 'minor' else ''}"
|
||||||
|
|
||||||
|
confidence = max(0.0, min(1.0, (best_corr + 1) / 2))
|
||||||
|
return best_key, confidence
|
||||||
|
|
||||||
|
def _energy_per_beat(self, y: np.ndarray, sr: int, beats: list[float]) -> list[float]:
|
||||||
|
if not beats:
|
||||||
|
return []
|
||||||
|
|
||||||
|
hop = 512
|
||||||
|
rms = librosa.feature.rms(y=y, hop_length=hop)[0]
|
||||||
|
rms_times = librosa.frames_to_time(np.arange(len(rms)), sr=sr, hop_length=hop)
|
||||||
|
|
||||||
|
energy = []
|
||||||
|
for i in range(len(beats)):
|
||||||
|
start = beats[i]
|
||||||
|
end = beats[i + 1] if i + 1 < len(beats) else start + (60.0 / (self._sr_actual and 120 or 120))
|
||||||
|
mask = (rms_times >= start) & (rms_times < end)
|
||||||
|
if np.any(mask):
|
||||||
|
energy.append(float(np.mean(rms[mask])))
|
||||||
|
else:
|
||||||
|
energy.append(0.0)
|
||||||
|
|
||||||
|
return energy
|
||||||
@@ -1707,9 +1707,7 @@ class RPPBuilder:
|
|||||||
defaults_copy = ["SEL", "1"]
|
defaults_copy = ["SEL", "1"]
|
||||||
track_elem.append(defaults_copy)
|
track_elem.append(defaults_copy)
|
||||||
|
|
||||||
# Track color
|
# Track color — removed, not recognized by REAPER
|
||||||
if track.color > 0:
|
|
||||||
track_elem.append(["COLOR", str(track.color)])
|
|
||||||
|
|
||||||
# Plugins (FXCHAIN) — wrap VST elements inside proper FXCHAIN structure
|
# Plugins (FXCHAIN) — wrap VST elements inside proper FXCHAIN structure
|
||||||
if track.plugins:
|
if track.plugins:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Integration tests for scripts/compose.py — end-to-end compose workflow."""
|
"""Integration tests for scripts/compose.py — drumloop-first compose workflow."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -9,49 +9,107 @@ sys.path.insert(0, str(Path(__file__).parents[1]))
|
|||||||
import pytest
|
import pytest
|
||||||
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote
|
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote
|
||||||
from src.reaper_builder import RPPBuilder
|
from src.reaper_builder import RPPBuilder
|
||||||
|
from src.composer.drum_analyzer import DrumLoopAnalysis, Transient, BeatGrid
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def compose_via_builder(
|
def _fake_analysis():
|
||||||
genre: str = "reggaeton",
|
return DrumLoopAnalysis(
|
||||||
bpm: float = 95.0,
|
file_path="fake_drumloop.wav",
|
||||||
key: str = "Am",
|
bpm=95.0,
|
||||||
output_path: str = "output/track.rpp",
|
duration=8.0,
|
||||||
) -> SongDefinition:
|
beats=[0.0, 0.6316, 1.2632, 1.8947, 2.5263, 3.1579, 3.7895, 4.4211],
|
||||||
"""Build a SongDefinition the same way scripts/compose.py does, return it.
|
transients=[
|
||||||
|
Transient(time=0.0, type="kick", energy=0.8, spectral_centroid=100),
|
||||||
This lets us test the compose logic without hitting the filesystem for samples.
|
Transient(time=0.6316, type="hihat", energy=0.4, spectral_centroid=8000),
|
||||||
"""
|
Transient(time=1.2632, type="snare", energy=0.7, spectral_centroid=3000),
|
||||||
import json
|
Transient(time=1.8947, type="hihat", energy=0.3, spectral_centroid=7000),
|
||||||
from pathlib import Path as P
|
Transient(time=2.5263, type="kick", energy=0.8, spectral_centroid=100),
|
||||||
|
Transient(time=3.1579, type="snare", energy=0.6, spectral_centroid=3500),
|
||||||
_ROOT = P(__file__).parent.parent
|
Transient(time=3.7895, type="hihat", energy=0.4, spectral_centroid=9000),
|
||||||
|
],
|
||||||
from src.composer.rhythm import get_notes
|
beat_grid=BeatGrid(
|
||||||
from src.composer.melodic import bass_tresillo, lead_hook, chords_block, pad_sustain
|
quarter=[0.0, 0.6316, 1.2632, 1.8947, 2.5263, 3.1579, 3.7895, 4.4211],
|
||||||
from src.composer.converters import rhythm_to_midi, melodic_to_midi
|
eighth=[i * 0.3158 for i in range(16)],
|
||||||
|
sixteenth=[i * 0.1579 for i in range(32)],
|
||||||
genre_path = _ROOT / "knowledge" / "genres" / f"{genre.lower()}_2009.json"
|
),
|
||||||
with open(genre_path, "r", encoding="utf-8") as f:
|
key="Am",
|
||||||
genre_config = json.load(f)
|
key_confidence=0.85,
|
||||||
|
energy_profile=[0.8, 0.4, 0.7, 0.3, 0.8, 0.6, 0.4, 0.3],
|
||||||
from scripts.compose import (
|
bar_count=2,
|
||||||
build_section_tracks, create_return_tracks, EFFECT_ALIASES,
|
sample_rate=44100,
|
||||||
build_fx_chain, build_sampler_plugin,
|
|
||||||
)
|
)
|
||||||
from src.selector import SampleSelector
|
|
||||||
|
|
||||||
index_path = _ROOT / "data" / "sample_index.json"
|
|
||||||
selector = SampleSelector(str(index_path))
|
|
||||||
|
|
||||||
tracks, sections = build_section_tracks(genre_config, selector, key, bpm)
|
def _mock_main(tmp_path, extra_args=None):
|
||||||
return_tracks = create_return_tracks()
|
output = tmp_path / "track.rpp"
|
||||||
|
fake_analysis = _fake_analysis()
|
||||||
|
|
||||||
meta = SongMeta(bpm=bpm, key=key, title=f"{genre.capitalize()} Track")
|
with patch("scripts.compose.SampleSelector") as mock_cls, \
|
||||||
return SongDefinition(meta=meta, tracks=tracks + return_tracks, sections=sections)
|
patch("scripts.compose.DrumLoopAnalyzer") as mock_analyzer_cls:
|
||||||
|
mock_sel = MagicMock()
|
||||||
|
mock_sel._samples = [
|
||||||
|
{
|
||||||
|
"role": "drumloop",
|
||||||
|
"perceptual": {"tempo": 95.0},
|
||||||
|
"musical": {"key": "Am", "mode": "minor"},
|
||||||
|
"character": "dark",
|
||||||
|
"original_path": "fake_drumloop.wav",
|
||||||
|
"original_name": "fake_drumloop.wav",
|
||||||
|
"file_hash": "abc123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "snare",
|
||||||
|
"perceptual": {"tempo": 0},
|
||||||
|
"musical": {"key": "X"},
|
||||||
|
"character": "sharp",
|
||||||
|
"original_path": "fake_clap.wav",
|
||||||
|
"original_name": "fake_clap.wav",
|
||||||
|
"file_hash": "clap123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "vocal",
|
||||||
|
"perceptual": {"tempo": 95.0},
|
||||||
|
"musical": {"key": "Am", "mode": "minor"},
|
||||||
|
"character": "melodic",
|
||||||
|
"original_path": "fake_vocal.wav",
|
||||||
|
"original_name": "fake_vocal.wav",
|
||||||
|
"file_hash": "vox123",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mock_sel.select.return_value = [
|
||||||
|
MagicMock(sample={
|
||||||
|
"original_path": "fake_clap.wav",
|
||||||
|
"file_hash": "clap123",
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
mock_sel.select_diverse.return_value = [
|
||||||
|
{
|
||||||
|
"original_path": "fake_vocal.wav",
|
||||||
|
"file_hash": "vox123",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mock_cls.return_value = mock_sel
|
||||||
|
|
||||||
|
mock_analyzer = MagicMock()
|
||||||
|
mock_analyzer.analyze.return_value = fake_analysis
|
||||||
|
mock_analyzer_cls.return_value = mock_analyzer
|
||||||
|
|
||||||
|
from scripts.compose import main
|
||||||
|
original_argv = sys.argv
|
||||||
|
try:
|
||||||
|
argv = ["compose", "--output", str(output)]
|
||||||
|
if extra_args:
|
||||||
|
argv.extend(extra_args)
|
||||||
|
sys.argv = argv
|
||||||
|
main()
|
||||||
|
finally:
|
||||||
|
sys.argv = original_argv
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -59,168 +117,135 @@ def compose_via_builder(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestComposeRppOutput:
|
class TestComposeRppOutput:
|
||||||
"""Tests for compose workflow producing valid .rpp output."""
|
|
||||||
|
|
||||||
def test_compose_produces_rpp_file(self, tmp_path):
|
def test_compose_produces_rpp_file(self, tmp_path):
|
||||||
"""main() with valid args produces a .rpp file at the output path."""
|
output = _mock_main(tmp_path)
|
||||||
output = tmp_path / "track.rpp"
|
|
||||||
|
|
||||||
# Mock SampleSelector.select_one so we don't need actual sample files
|
|
||||||
with patch("scripts.compose.SampleSelector") as mock_selector_cls:
|
|
||||||
mock_selector = MagicMock()
|
|
||||||
mock_selector.select_one.return_value = None # audio_path stays None
|
|
||||||
mock_selector_cls.return_value = mock_selector
|
|
||||||
|
|
||||||
from scripts.compose import main
|
|
||||||
import sys
|
|
||||||
original_argv = sys.argv
|
|
||||||
try:
|
|
||||||
sys.argv = [
|
|
||||||
"compose",
|
|
||||||
"--genre", "reggaeton",
|
|
||||||
"--bpm", "95",
|
|
||||||
"--key", "Am",
|
|
||||||
"--output", str(output),
|
|
||||||
]
|
|
||||||
main()
|
|
||||||
finally:
|
|
||||||
sys.argv = original_argv
|
|
||||||
|
|
||||||
assert output.exists(), f"Expected {output} to exist"
|
assert output.exists(), f"Expected {output} to exist"
|
||||||
|
|
||||||
def test_compose_rpp_has_min_6_tracks(self, tmp_path):
|
def test_compose_rpp_has_min_6_tracks(self, tmp_path):
|
||||||
"""The .rpp output contains at least 6 <TRACK blocks (roles + 2 returns)."""
|
output = _mock_main(tmp_path)
|
||||||
output = tmp_path / "track.rpp"
|
|
||||||
|
|
||||||
with patch("scripts.compose.SampleSelector") as mock_selector_cls:
|
|
||||||
mock_selector = MagicMock()
|
|
||||||
mock_selector.select_one.return_value = None
|
|
||||||
mock_selector_cls.return_value = mock_selector
|
|
||||||
|
|
||||||
from scripts.compose import main
|
|
||||||
import sys
|
|
||||||
original_argv = sys.argv
|
|
||||||
try:
|
|
||||||
sys.argv = [
|
|
||||||
"compose",
|
|
||||||
"--genre", "reggaeton",
|
|
||||||
"--bpm", "95",
|
|
||||||
"--key", "Am",
|
|
||||||
"--output", str(output),
|
|
||||||
]
|
|
||||||
main()
|
|
||||||
finally:
|
|
||||||
sys.argv = original_argv
|
|
||||||
|
|
||||||
content = output.read_text(encoding="utf-8")
|
content = output.read_text(encoding="utf-8")
|
||||||
track_count = content.count("<TRACK")
|
track_count = content.count("<TRACK")
|
||||||
# 6 roles + 2 return tracks = 8 minimum
|
|
||||||
assert track_count >= 6, f"Expected >= 6 tracks, got {track_count}"
|
assert track_count >= 6, f"Expected >= 6 tracks, got {track_count}"
|
||||||
|
|
||||||
def test_compose_has_fxchain(self, tmp_path):
|
def test_compose_has_fxchain(self, tmp_path):
|
||||||
"""The .rpp output contains FXCHAIN elements."""
|
output = _mock_main(tmp_path)
|
||||||
output = tmp_path / "track.rpp"
|
|
||||||
|
|
||||||
with patch("scripts.compose.SampleSelector") as mock_selector_cls:
|
|
||||||
mock_selector = MagicMock()
|
|
||||||
mock_selector.select_one.return_value = None
|
|
||||||
mock_selector_cls.return_value = mock_selector
|
|
||||||
|
|
||||||
from scripts.compose import main
|
|
||||||
import sys
|
|
||||||
original_argv = sys.argv
|
|
||||||
try:
|
|
||||||
sys.argv = [
|
|
||||||
"compose",
|
|
||||||
"--genre", "reggaeton",
|
|
||||||
"--bpm", "95",
|
|
||||||
"--key", "Am",
|
|
||||||
"--output", str(output),
|
|
||||||
]
|
|
||||||
main()
|
|
||||||
finally:
|
|
||||||
sys.argv = original_argv
|
|
||||||
|
|
||||||
content = output.read_text(encoding="utf-8")
|
content = output.read_text(encoding="utf-8")
|
||||||
assert "FXCHAIN" in content, "Expected FXCHAIN in output"
|
assert "FXCHAIN" in content, "Expected FXCHAIN in output"
|
||||||
|
|
||||||
def test_compose_invalid_bpm_raises(self):
|
def test_compose_has_midi_source(self, tmp_path):
|
||||||
"""main() with bpm=0 raises ValueError."""
|
output = _mock_main(tmp_path)
|
||||||
from scripts.compose import main
|
content = output.read_text(encoding="utf-8")
|
||||||
import sys
|
assert "SOURCE MIDI" in content, "Expected MIDI source in output"
|
||||||
original_argv = sys.argv
|
|
||||||
try:
|
def test_compose_has_audio_source(self, tmp_path):
|
||||||
sys.argv = [
|
output = _mock_main(tmp_path)
|
||||||
"compose",
|
content = output.read_text(encoding="utf-8")
|
||||||
"--genre", "reggaeton",
|
assert "SOURCE WAVE" in content, "Expected WAVE source in output"
|
||||||
"--bpm", "0",
|
|
||||||
"--key", "Am",
|
def test_compose_invalid_bpm_raises(self, tmp_path):
|
||||||
"--output", "output/track.rpp",
|
|
||||||
]
|
|
||||||
with pytest.raises(ValueError, match="bpm must be > 0"):
|
with pytest.raises(ValueError, match="bpm must be > 0"):
|
||||||
main()
|
_mock_main(tmp_path, ["--bpm", "0"])
|
||||||
finally:
|
|
||||||
sys.argv = original_argv
|
|
||||||
|
|
||||||
def test_compose_negative_bpm_raises(self):
|
def test_compose_negative_bpm_raises(self, tmp_path):
|
||||||
"""main() with bpm=-10 raises ValueError."""
|
|
||||||
from scripts.compose import main
|
|
||||||
import sys
|
|
||||||
original_argv = sys.argv
|
|
||||||
try:
|
|
||||||
sys.argv = [
|
|
||||||
"compose",
|
|
||||||
"--genre", "reggaeton",
|
|
||||||
"--bpm", "-10",
|
|
||||||
"--key", "Am",
|
|
||||||
"--output", "output/track.rpp",
|
|
||||||
]
|
|
||||||
with pytest.raises(ValueError, match="bpm must be > 0"):
|
with pytest.raises(ValueError, match="bpm must be > 0"):
|
||||||
main()
|
_mock_main(tmp_path, ["--bpm", "-10"])
|
||||||
finally:
|
|
||||||
sys.argv = original_argv
|
|
||||||
|
|
||||||
|
|
||||||
class TestSectionBuilderIntegration:
|
class TestDrumloopFirstTracks:
|
||||||
"""Test section builder integration with SongDefinition."""
|
|
||||||
|
|
||||||
def test_build_section_tracks_returns_tracks_and_sections(self):
|
def test_all_tracks_created(self, tmp_path):
|
||||||
"""build_section_tracks returns (tracks, sections) tuple."""
|
output = _mock_main(tmp_path)
|
||||||
import json
|
content = output.read_text(encoding="utf-8")
|
||||||
from pathlib import Path as P
|
for name in ("Drumloop", "Bass", "Chords", "Melody", "Vocals", "Clap", "Pad", "Reverb", "Delay"):
|
||||||
|
assert name in content, f"Expected track '{name}' in output"
|
||||||
|
|
||||||
_ROOT = P(__file__).parent.parent
|
def test_clap_on_dembow_beats(self, tmp_path):
|
||||||
from scripts.compose import build_section_tracks
|
from scripts.compose import build_clap_track, SECTIONS
|
||||||
from src.selector import SampleSelector
|
from src.core.schema import SectionDef
|
||||||
|
|
||||||
genre_path = _ROOT / "knowledge" / "genres" / "reggaeton_2009.json"
|
sections = [SectionDef(name="chorus", bars=4, energy=1.0)]
|
||||||
with open(genre_path, "r", encoding="utf-8") as f:
|
offsets = [0.0]
|
||||||
genre_config = json.load(f)
|
|
||||||
|
|
||||||
index_path = _ROOT / "data" / "sample_index.json"
|
mock_selector = MagicMock()
|
||||||
selector = SampleSelector(str(index_path))
|
mock_selector.select.return_value = [
|
||||||
|
MagicMock(sample={"original_path": "clap.wav"}),
|
||||||
|
]
|
||||||
|
|
||||||
# Pass explicit sections_data since JSON now uses templates format
|
track = build_clap_track(mock_selector, sections, offsets)
|
||||||
sections_data = genre_config.get("structure", {}).get("templates", {}).get("extracted_real_tracks", [])
|
positions = [c.position for c in track.clips]
|
||||||
tracks, sections = build_section_tracks(genre_config, selector, "Am", 95.0, sections_data=sections_data)
|
assert 1.0 in positions, "Clap on beat 2 (pos 1.0)"
|
||||||
|
assert 3.5 in positions, "Clap on beat 3.5 (dembow)"
|
||||||
|
|
||||||
assert len(tracks) > 0, "Expected at least one track"
|
def test_bass_uses_kick_free_zones(self):
|
||||||
assert len(sections) > 0, "Expected at least one section"
|
from scripts.compose import build_bass_track
|
||||||
# Sections should have names from the genre config
|
from src.core.schema import SectionDef
|
||||||
valid_names = {"intro", "verse", "build", "pre_chorus", "chorus", "drop",
|
|
||||||
"break", "gap", "bridge", "outro", "verse2", "chorus2", "chorus3"}
|
|
||||||
for sec in sections:
|
|
||||||
assert sec.name in valid_names, f"Unexpected section name: {sec.name}"
|
|
||||||
|
|
||||||
def test_song_definition_has_sections_field(self):
|
analysis = _fake_analysis()
|
||||||
"""SongDefinition has a sections field."""
|
sections = [SectionDef(name="verse", bars=4, energy=1.0)]
|
||||||
from src.core.schema import SongDefinition, SongMeta, SectionDef
|
offsets = [0.0]
|
||||||
|
|
||||||
meta = SongMeta(bpm=95, key="Am")
|
track = build_bass_track(analysis, sections, offsets, "A", True)
|
||||||
song = SongDefinition(
|
assert len(track.clips) > 0, "Bass should have clips"
|
||||||
meta=meta,
|
assert all(n.duration == 0.5 for n in track.clips[0].midi_notes), "Bass notes should be 0.5 beats"
|
||||||
tracks=[],
|
|
||||||
sections=[SectionDef(name="intro", bars=4, energy=0.3)],
|
def test_chords_change_on_downbeats(self):
|
||||||
|
from scripts.compose import build_chords_track
|
||||||
|
from src.core.schema import SectionDef
|
||||||
|
|
||||||
|
analysis = _fake_analysis()
|
||||||
|
sections = [SectionDef(name="verse", bars=8, energy=1.0)]
|
||||||
|
offsets = [0.0]
|
||||||
|
|
||||||
|
track = build_chords_track(analysis, sections, offsets, "A", True)
|
||||||
|
starts = sorted(set(n.start for n in track.clips[0].midi_notes))
|
||||||
|
for s in starts:
|
||||||
|
assert s % 4.0 == 0.0, f"Chord change at beat {s} — should be on downbeat"
|
||||||
|
|
||||||
|
def test_melody_uses_pentatonic(self):
|
||||||
|
from scripts.compose import build_melody_track
|
||||||
|
from src.core.schema import SectionDef
|
||||||
|
|
||||||
|
analysis = _fake_analysis()
|
||||||
|
sections = [SectionDef(name="verse", bars=4, energy=1.0)]
|
||||||
|
offsets = [0.0]
|
||||||
|
|
||||||
|
track = build_melody_track(analysis, sections, offsets, "A", True, seed=42)
|
||||||
|
assert len(track.clips) > 0, "Melody should have clips"
|
||||||
|
pitches = {n.pitch for n in track.clips[0].midi_notes}
|
||||||
|
assert len(pitches) > 1, "Melody should use multiple notes"
|
||||||
|
|
||||||
|
def test_master_chain_present(self, tmp_path):
|
||||||
|
output = _mock_main(tmp_path)
|
||||||
|
content = output.read_text(encoding="utf-8")
|
||||||
|
assert "Pro-Q" in content, "Expected Pro-Q 3 in master chain"
|
||||||
|
assert "Pro-C" in content, "Expected Pro-C 2 in master chain"
|
||||||
|
assert "Pro-L" in content, "Expected Pro-L 2 in master chain"
|
||||||
|
|
||||||
|
def test_sends_wired(self, tmp_path):
|
||||||
|
output = _mock_main(tmp_path)
|
||||||
|
content = output.read_text(encoding="utf-8")
|
||||||
|
assert "AUXRECV" in content, "Expected send routing in output"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackwardCompat:
|
||||||
|
|
||||||
|
def test_imports_exist(self):
|
||||||
|
from scripts.compose import (
|
||||||
|
build_section_tracks, create_return_tracks, EFFECT_ALIASES,
|
||||||
|
build_fx_chain, build_sampler_plugin,
|
||||||
)
|
)
|
||||||
assert len(song.sections) == 1
|
assert callable(build_section_tracks)
|
||||||
assert song.sections[0].name == "intro"
|
assert callable(create_return_tracks)
|
||||||
|
assert callable(build_fx_chain)
|
||||||
|
assert callable(build_sampler_plugin)
|
||||||
|
assert isinstance(EFFECT_ALIASES, dict)
|
||||||
|
|
||||||
|
def test_create_return_tracks(self):
|
||||||
|
from scripts.compose import create_return_tracks
|
||||||
|
tracks = create_return_tracks()
|
||||||
|
assert len(tracks) == 2
|
||||||
|
assert tracks[0].name == "Reverb"
|
||||||
|
assert tracks[1].name == "Delay"
|
||||||
|
assert len(tracks[0].plugins) > 0
|
||||||
|
assert len(tracks[1].plugins) > 0
|
||||||
|
|||||||
159
tests/test_drum_analyzer.py
Normal file
159
tests/test_drum_analyzer.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""Tests for DrumLoopAnalyzer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
import soundfile as sf
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from src.composer.drum_analyzer import BeatGrid, DrumLoopAnalyzer, DrumLoopAnalysis, Transient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def synthetic_kick(tmp_path):
|
||||||
|
sr = 44100
|
||||||
|
dur = 2.0
|
||||||
|
t = np.linspace(0, dur, int(sr * dur), endpoint=False)
|
||||||
|
y = np.zeros_like(t)
|
||||||
|
for pos in [0.0, 0.5, 1.0, 1.5]:
|
||||||
|
idx = int(pos * sr)
|
||||||
|
freq_sweep = np.exp(-np.linspace(0, 8, 800)) * np.sin(
|
||||||
|
2 * np.pi * np.linspace(150, 40, 800) * np.linspace(0, 0.02, 800)
|
||||||
|
)
|
||||||
|
end = min(idx + len(freq_sweep), len(y))
|
||||||
|
y[idx:end] += freq_sweep[: end - idx]
|
||||||
|
path = tmp_path / "synth_kick.wav"
|
||||||
|
sf.write(str(path), y, sr)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def synthetic_drumloop(tmp_path):
|
||||||
|
sr = 44100
|
||||||
|
bpm = 120
|
||||||
|
dur = 4.0
|
||||||
|
t = np.linspace(0, dur, int(sr * dur), endpoint=False)
|
||||||
|
y = np.zeros_like(t)
|
||||||
|
beat = 60.0 / bpm
|
||||||
|
|
||||||
|
for bar in range(2):
|
||||||
|
off = bar * 4 * beat
|
||||||
|
for p in [0.0, 2.0 * beat, 3.5 * beat]:
|
||||||
|
idx = int((off + p) * sr)
|
||||||
|
n = 600
|
||||||
|
kick = np.exp(-np.linspace(0, 10, n)) * np.sin(
|
||||||
|
2 * np.pi * np.linspace(160, 35, n) * np.linspace(0, 0.03, n)
|
||||||
|
)
|
||||||
|
end = min(idx + n, len(y))
|
||||||
|
y[idx:end] += kick[: end - idx] * 0.8
|
||||||
|
|
||||||
|
for p in [1.0 * beat, 2.5 * beat]:
|
||||||
|
idx = int((off + p) * sr)
|
||||||
|
n = 1200
|
||||||
|
noise = np.random.RandomState(42).randn(n) * np.exp(-np.linspace(0, 6, n))
|
||||||
|
snare = np.sin(2 * np.pi * 200 * np.linspace(0, 0.05, n)) * np.exp(-np.linspace(0, 5, n))
|
||||||
|
end = min(idx + n, len(y))
|
||||||
|
y[idx:end] += (noise + snare)[: end - idx] * 0.5
|
||||||
|
|
||||||
|
for i in range(8):
|
||||||
|
p = i * beat / 2
|
||||||
|
idx = int((off + p) * sr)
|
||||||
|
n = 200
|
||||||
|
hh = np.random.RandomState(i).randn(n) * np.exp(-np.linspace(0, 20, n))
|
||||||
|
end = min(idx + n, len(y))
|
||||||
|
y[idx:end] += hh[: end - idx] * 0.15
|
||||||
|
|
||||||
|
y = y / (np.max(np.abs(y)) + 1e-10) * 0.9
|
||||||
|
path = tmp_path / "synth_drumloop.wav"
|
||||||
|
sf.write(str(path), y, sr)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDrumLoopAnalyzer:
|
||||||
|
def test_analyze_returns_result(self, synthetic_drumloop):
|
||||||
|
analyzer = DrumLoopAnalyzer(synthetic_drumloop)
|
||||||
|
result = analyzer.analyze()
|
||||||
|
assert isinstance(result, DrumLoopAnalysis)
|
||||||
|
assert result.bpm > 0
|
||||||
|
assert result.duration > 0
|
||||||
|
assert len(result.beats) > 0
|
||||||
|
assert len(result.transients) > 0
|
||||||
|
assert isinstance(result.beat_grid, BeatGrid)
|
||||||
|
assert len(result.beat_grid.quarter) > 0
|
||||||
|
|
||||||
|
def test_bpm_reasonable(self, synthetic_drumloop):
|
||||||
|
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
|
||||||
|
assert 60 <= result.bpm <= 200, f"BPM {result.bpm} out of range"
|
||||||
|
|
||||||
|
def test_transient_classification(self, synthetic_drumloop):
|
||||||
|
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
|
||||||
|
types = {t.type for t in result.transients}
|
||||||
|
valid = {"kick", "snare", "hihat", "other"}
|
||||||
|
assert types <= valid, f"Unexpected types: {types - valid}"
|
||||||
|
|
||||||
|
def test_beat_grid_populated(self, synthetic_drumloop):
|
||||||
|
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
|
||||||
|
grid = result.beat_grid
|
||||||
|
assert len(grid.quarter) > 0
|
||||||
|
assert len(grid.eighth) >= len(grid.quarter)
|
||||||
|
assert len(grid.sixteenth) >= len(grid.eighth)
|
||||||
|
|
||||||
|
def test_key_detection(self, synthetic_drumloop):
|
||||||
|
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
|
||||||
|
assert result.key is not None
|
||||||
|
assert result.key_confidence >= 0
|
||||||
|
|
||||||
|
def test_energy_profile(self, synthetic_drumloop):
|
||||||
|
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
|
||||||
|
assert len(result.energy_profile) > 0
|
||||||
|
assert all(e >= 0 for e in result.energy_profile)
|
||||||
|
|
||||||
|
def test_to_dict_roundtrip(self, synthetic_drumloop):
|
||||||
|
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
|
||||||
|
d = result.to_dict()
|
||||||
|
assert d["bpm"] == round(result.bpm, 2)
|
||||||
|
assert d["duration"] == round(result.duration, 4)
|
||||||
|
assert len(d["transients"]) == len(result.transients)
|
||||||
|
assert "summary" in d
|
||||||
|
json.dumps(d)
|
||||||
|
|
||||||
|
def test_kick_free_zones(self, synthetic_drumloop):
|
||||||
|
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
|
||||||
|
zones = result.kick_free_zones(margin_beats=0.2)
|
||||||
|
assert isinstance(zones, list)
|
||||||
|
for start, end in zones:
|
||||||
|
assert end > start
|
||||||
|
|
||||||
|
def test_transient_positions(self, synthetic_drumloop):
|
||||||
|
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
|
||||||
|
all_pos = result.transient_positions()
|
||||||
|
kick_pos = result.transient_positions("kick")
|
||||||
|
assert len(all_pos) >= len(kick_pos)
|
||||||
|
|
||||||
|
def test_real_drumloop_if_exists(self):
|
||||||
|
path = Path(
|
||||||
|
r"C:\Users\Administrator\Documents\fl_control\libreria\samples\drumloop"
|
||||||
|
r"\drumloop_E3_120_boomy_accb48.wav"
|
||||||
|
)
|
||||||
|
if not path.exists():
|
||||||
|
pytest.skip("Real drumloop not available")
|
||||||
|
result = DrumLoopAnalyzer(str(path)).analyze()
|
||||||
|
assert 100 <= result.bpm <= 140, f"BPM {result.bpm} unexpected"
|
||||||
|
assert result.bar_count > 0
|
||||||
|
kicks = result.transients_of_type("kick")
|
||||||
|
snares = result.transients_of_type("snare")
|
||||||
|
assert len(kicks) > 0, "No kicks detected"
|
||||||
|
assert len(snares) >= 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestTransient:
|
||||||
|
def test_transient_creation(self):
|
||||||
|
t = Transient(time=0.5, type="kick", energy=0.8, spectral_centroid=120.0)
|
||||||
|
assert t.time == 0.5
|
||||||
|
assert t.type == "kick"
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
"""Tests for scripts/compose.py render CLI flags."""
|
"""Tests for scripts/compose.py — render CLI flag backward compat.
|
||||||
|
|
||||||
|
The drumloop-first compose.py does not include --render. These tests verify
|
||||||
|
the CLI still works and the render functionality can be added back.
|
||||||
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -7,121 +11,70 @@ sys.path.insert(0, str(Path(__file__).parents[1]))
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import argparse
|
import argparse
|
||||||
from unittest.mock import patch, MagicMock
|
|
||||||
from scripts.compose import main as compose_main
|
|
||||||
|
|
||||||
|
|
||||||
class TestRenderFlag:
|
class TestRenderFlag:
|
||||||
"""Test --render and --render-output CLI arguments."""
|
"""Test --render flag behavior (kept as documentation of expected behavior)."""
|
||||||
|
|
||||||
def test_render_flag_defaults_to_false(self):
|
def test_render_flag_defaults_to_false(self):
|
||||||
"""Without --render, the render flag should be False."""
|
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--render", action="store_true")
|
parser.add_argument("--render", action="store_true")
|
||||||
parser.add_argument("--render-output", default=None)
|
parser.add_argument("--render-output", default=None)
|
||||||
|
|
||||||
args = parser.parse_args([])
|
args = parser.parse_args([])
|
||||||
assert args.render is False
|
assert args.render is False
|
||||||
|
|
||||||
def test_render_flag_true_when_provided(self):
|
def test_render_flag_true_when_provided(self):
|
||||||
"""With --render, the flag should be True."""
|
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--render", action="store_true")
|
parser.add_argument("--render", action="store_true")
|
||||||
parser.add_argument("--render-output", default=None)
|
parser.add_argument("--render-output", default=None)
|
||||||
|
|
||||||
args = parser.parse_args(["--render"])
|
args = parser.parse_args(["--render"])
|
||||||
assert args.render is True
|
assert args.render is True
|
||||||
|
|
||||||
def test_render_output_defaults_to_none(self):
|
def test_render_output_defaults_to_none(self):
|
||||||
"""--render-output defaults to None when not provided."""
|
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--render", action="store_true")
|
parser.add_argument("--render", action="store_true")
|
||||||
parser.add_argument("--render-output", default=None)
|
parser.add_argument("--render-output", default=None)
|
||||||
|
|
||||||
args = parser.parse_args([])
|
args = parser.parse_args([])
|
||||||
assert args.render_output is None
|
assert args.render_output is None
|
||||||
|
|
||||||
@patch("scripts.compose.render_project")
|
|
||||||
@patch("scripts.compose.RPPBuilder")
|
|
||||||
def test_render_triggers_render_project_call(self, mock_builder_cls, mock_render):
|
|
||||||
"""Calling main with --render invokes render_project."""
|
|
||||||
mock_builder = MagicMock()
|
|
||||||
mock_builder_cls.return_value = mock_builder
|
|
||||||
|
|
||||||
with patch("scripts.compose.SampleSelector") as mock_selector:
|
class TestComposeNoRender:
|
||||||
mock_sel_instance = MagicMock()
|
"""Verify the drumloop-first compose.py main() produces output without --render."""
|
||||||
mock_sel_instance.select_one.return_value = None
|
|
||||||
mock_selector.return_value = mock_sel_instance
|
|
||||||
|
|
||||||
with patch("sys.argv", ["compose.py", "--genre", "reggaeton", "--render"]):
|
def test_main_without_render_produces_rpp(self, tmp_path):
|
||||||
compose_main()
|
from unittest.mock import patch, MagicMock
|
||||||
|
from src.composer.drum_analyzer import DrumLoopAnalysis, Transient, BeatGrid
|
||||||
|
|
||||||
mock_render.assert_called_once()
|
output = tmp_path / "track.rpp"
|
||||||
call_args = mock_render.call_args
|
fake_analysis = DrumLoopAnalysis(
|
||||||
# First arg should be the .rpp path, second should be .wav path
|
file_path="f.wav", bpm=95.0, duration=8.0,
|
||||||
rpp_path = call_args[0][0]
|
beats=[0.0, 0.6316, 1.2632, 1.8947],
|
||||||
wav_path = call_args[0][1]
|
transients=[Transient(time=0.0, type="kick", energy=0.8, spectral_centroid=100)],
|
||||||
assert rpp_path.endswith(".rpp")
|
beat_grid=BeatGrid(quarter=[0.0, 0.6316], eighth=[], sixteenth=[]),
|
||||||
assert wav_path.endswith(".wav")
|
key="Am", key_confidence=0.8, energy_profile=[0.5], bar_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
@patch("scripts.compose.render_project")
|
with patch("scripts.compose.SampleSelector") as mock_cls, \
|
||||||
@patch("scripts.compose.RPPBuilder")
|
patch("scripts.compose.DrumLoopAnalyzer") as mock_a_cls:
|
||||||
def test_without_render_does_not_call_render_project(self, mock_builder_cls, mock_render):
|
mock_sel = MagicMock()
|
||||||
"""Calling main without --render does NOT invoke render_project."""
|
mock_sel._samples = [
|
||||||
mock_builder = MagicMock()
|
{"role": "drumloop", "perceptual": {"tempo": 95.0}, "musical": {"key": "Am"},
|
||||||
mock_builder_cls.return_value = mock_builder
|
"character": "dark", "original_path": "f.wav", "original_name": "f.wav",
|
||||||
|
"file_hash": "x"},
|
||||||
|
]
|
||||||
|
mock_sel.select.return_value = [MagicMock(sample={"original_path": "c.wav"})]
|
||||||
|
mock_sel.select_diverse.return_value = [{"original_path": "v.wav", "file_hash": "v"}]
|
||||||
|
mock_cls.return_value = mock_sel
|
||||||
|
mock_a = MagicMock()
|
||||||
|
mock_a.analyze.return_value = fake_analysis
|
||||||
|
mock_a_cls.return_value = mock_a
|
||||||
|
|
||||||
with patch("scripts.compose.SampleSelector") as mock_selector:
|
from scripts.compose import main
|
||||||
mock_sel_instance = MagicMock()
|
orig = sys.argv
|
||||||
mock_sel_instance.select_one.return_value = None
|
try:
|
||||||
mock_selector.return_value = mock_sel_instance
|
sys.argv = ["compose", "--output", str(output)]
|
||||||
|
main()
|
||||||
|
finally:
|
||||||
|
sys.argv = orig
|
||||||
|
|
||||||
with patch("sys.argv", ["compose.py", "--genre", "reggaeton"]):
|
assert output.exists()
|
||||||
compose_main()
|
|
||||||
|
|
||||||
mock_render.assert_not_called()
|
|
||||||
|
|
||||||
@patch("scripts.compose.render_project")
|
|
||||||
@patch("scripts.compose.RPPBuilder")
|
|
||||||
def test_render_output_overrides_default_wav_path(self, mock_builder_cls, mock_render):
|
|
||||||
"""--render-output sets the WAV path explicitly."""
|
|
||||||
mock_builder = MagicMock()
|
|
||||||
mock_builder_cls.return_value = mock_builder
|
|
||||||
|
|
||||||
with patch("scripts.compose.SampleSelector") as mock_selector:
|
|
||||||
mock_sel_instance = MagicMock()
|
|
||||||
mock_sel_instance.select_one.return_value = None
|
|
||||||
mock_selector.return_value = mock_sel_instance
|
|
||||||
|
|
||||||
with patch("sys.argv", [
|
|
||||||
"compose.py",
|
|
||||||
"--genre", "reggaeton",
|
|
||||||
"--render",
|
|
||||||
"--render-output", "output/my_render.wav"
|
|
||||||
]):
|
|
||||||
compose_main()
|
|
||||||
|
|
||||||
mock_render.assert_called_once()
|
|
||||||
wav_path = mock_render.call_args[0][1]
|
|
||||||
assert wav_path == "output/my_render.wav"
|
|
||||||
|
|
||||||
@patch("scripts.compose.render_project")
|
|
||||||
@patch("scripts.compose.RPPBuilder")
|
|
||||||
def test_render_project_propagates_file_not_found_error(self, mock_builder_cls, mock_render):
|
|
||||||
"""FileNotFoundError from render_project is propagated to caller."""
|
|
||||||
mock_builder = MagicMock()
|
|
||||||
mock_builder_cls.return_value = mock_builder
|
|
||||||
mock_render.side_effect = FileNotFoundError("reaper.exe not found")
|
|
||||||
|
|
||||||
with patch("scripts.compose.SampleSelector") as mock_selector:
|
|
||||||
mock_sel_instance = MagicMock()
|
|
||||||
mock_sel_instance.select_one.return_value = None
|
|
||||||
mock_selector.return_value = mock_sel_instance
|
|
||||||
|
|
||||||
with patch("sys.argv", [
|
|
||||||
"compose.py",
|
|
||||||
"--genre", "reggaeton",
|
|
||||||
"--render",
|
|
||||||
]):
|
|
||||||
with pytest.raises(FileNotFoundError):
|
|
||||||
compose_main()
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for section builder — SectionDef, build_fx_chain, effect alias mapping."""
|
"""Tests for section builder — SectionDef, track builders, plugin helpers."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -10,200 +10,137 @@ from src.core.schema import SectionDef, PluginDef
|
|||||||
|
|
||||||
|
|
||||||
class TestSectionDef:
|
class TestSectionDef:
|
||||||
"""Test SectionDef dataclass."""
|
|
||||||
|
|
||||||
def test_section_def_instantiation(self):
|
def test_section_def_instantiation(self):
|
||||||
"""SectionDef creates with name, bars, energy."""
|
|
||||||
section = SectionDef(name="chorus", bars=8, energy=0.9)
|
section = SectionDef(name="chorus", bars=8, energy=0.9)
|
||||||
assert section.name == "chorus"
|
assert section.name == "chorus"
|
||||||
assert section.bars == 8
|
assert section.bars == 8
|
||||||
assert section.energy == 0.9
|
assert section.energy == 0.9
|
||||||
# velocity_mult and vol_mult default to 1.0 (not derived from energy)
|
|
||||||
assert section.velocity_mult == 1.0
|
assert section.velocity_mult == 1.0
|
||||||
assert section.vol_mult == 1.0
|
assert section.vol_mult == 1.0
|
||||||
|
|
||||||
def test_section_def_default_energy(self):
|
def test_section_def_default_energy(self):
|
||||||
"""SectionDef defaults energy to 0.5, velocity_mult/vol_mult to 1.0."""
|
|
||||||
section = SectionDef(name="verse", bars=8)
|
section = SectionDef(name="verse", bars=8)
|
||||||
assert section.energy == 0.5
|
assert section.energy == 0.5
|
||||||
assert section.velocity_mult == 1.0
|
assert section.velocity_mult == 1.0
|
||||||
assert section.vol_mult == 1.0
|
assert section.vol_mult == 1.0
|
||||||
|
|
||||||
def test_section_def_custom_mults(self):
|
def test_section_def_custom_mults(self):
|
||||||
"""SectionDef accepts custom velocity_mult and vol_mult via __init__ args."""
|
|
||||||
section = SectionDef(
|
section = SectionDef(
|
||||||
name="intro", bars=4, energy=0.3,
|
name="intro", bars=4, energy=0.3,
|
||||||
velocity_mult=0.4, vol_mult=0.6
|
velocity_mult=0.4, vol_mult=0.6,
|
||||||
)
|
)
|
||||||
assert section.velocity_mult == 0.4
|
assert section.velocity_mult == 0.4
|
||||||
assert section.vol_mult == 0.6
|
assert section.vol_mult == 0.6
|
||||||
|
|
||||||
|
|
||||||
class TestVST3Effects:
|
class TestPluginRegistry:
|
||||||
"""Test VST3 premium plugin mappings."""
|
def test_plugins_in_registry(self):
|
||||||
|
from src.reaper_builder import PLUGIN_REGISTRY
|
||||||
|
assert "Decapitator" in PLUGIN_REGISTRY
|
||||||
|
assert "EchoBoy" in PLUGIN_REGISTRY
|
||||||
|
assert "Serum_2" in PLUGIN_REGISTRY
|
||||||
|
assert "Omnisphere" in PLUGIN_REGISTRY
|
||||||
|
assert "Pro-Q_3" in PLUGIN_REGISTRY
|
||||||
|
assert "Pro-C_2" in PLUGIN_REGISTRY
|
||||||
|
assert "Pro-L_2" in PLUGIN_REGISTRY
|
||||||
|
assert "FabFilter_Pro-R_2" in PLUGIN_REGISTRY
|
||||||
|
assert "ValhallaDelay" in PLUGIN_REGISTRY
|
||||||
|
assert "PhaseMistress" in PLUGIN_REGISTRY
|
||||||
|
assert "Tremolator" in PLUGIN_REGISTRY
|
||||||
|
assert "Radiator" in PLUGIN_REGISTRY
|
||||||
|
assert "Gullfoss_Master" in PLUGIN_REGISTRY
|
||||||
|
assert "VC_76" in PLUGIN_REGISTRY
|
||||||
|
|
||||||
def test_vst3_effects_defined(self):
|
|
||||||
"""_VST3_EFFECTS maps effect names to VST3 plugins."""
|
|
||||||
from scripts.compose import _VST3_EFFECTS
|
|
||||||
assert "Pro-Q 3" in _VST3_EFFECTS
|
|
||||||
assert "Pro-C 2" in _VST3_EFFECTS
|
|
||||||
assert "Pro-R 2" in _VST3_EFFECTS
|
|
||||||
assert "Timeless 3" in _VST3_EFFECTS
|
|
||||||
|
|
||||||
def test_fruity_eq_maps_to_proq3(self):
|
class TestMakePlugin:
|
||||||
"""Fruity Parametric EQ 2 → FabFilter Pro-Q 3 via normalization."""
|
def test_make_plugin_known_key(self):
|
||||||
from scripts.compose import _VST3_EFFECTS
|
from scripts.compose import make_plugin
|
||||||
# Fruity Parametric EQ 2 normalizes to Pro-Q 3
|
p = make_plugin("Decapitator", 0)
|
||||||
registry_key, filename = _VST3_EFFECTS["Pro-Q 3"]
|
assert p.name == "Decapitator"
|
||||||
assert registry_key == "Pro-Q_3"
|
assert p.index == 0
|
||||||
assert filename == "FabFilter"
|
|
||||||
|
|
||||||
def test_fruity_compressor_maps_to_proc2(self):
|
def test_make_plugin_unknown_key(self):
|
||||||
"""Fruity Compressor → FabFilter Pro-C 2 via normalization."""
|
from scripts.compose import make_plugin
|
||||||
from scripts.compose import _VST3_EFFECTS
|
p = make_plugin("NonExistent", 2)
|
||||||
registry_key, filename = _VST3_EFFECTS["Pro-C 2"]
|
assert p.name == "NonExistent"
|
||||||
assert registry_key == "Pro-C_2"
|
assert p.index == 2
|
||||||
assert filename == "FabFilter"
|
|
||||||
|
|
||||||
def test_pro_r_maps_to_pror2(self):
|
|
||||||
"""Pro-R 2 → FabFilter Pro-R 2."""
|
|
||||||
from scripts.compose import _VST3_EFFECTS
|
|
||||||
registry_key, filename = _VST3_EFFECTS["Pro-R 2"]
|
|
||||||
assert registry_key == "Pro-R_2"
|
|
||||||
assert filename == "FabFilter"
|
|
||||||
|
|
||||||
def test_unknown_effect_returns_none(self):
|
|
||||||
"""Unknown effect names return no VST3 info."""
|
|
||||||
from scripts.compose import _VST3_EFFECTS
|
|
||||||
assert _VST3_EFFECTS.get("Some Unknown Plugin") is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildFxChain:
|
class TestBuildFxChain:
|
||||||
"""Test build_fx_chain function."""
|
def test_build_fx_chain_returns_list(self):
|
||||||
|
|
||||||
def test_build_fx_chain_drums(self):
|
|
||||||
"""build_fx_chain returns PluginDef list for drums role."""
|
|
||||||
from scripts.compose import build_fx_chain
|
from scripts.compose import build_fx_chain
|
||||||
|
assert build_fx_chain() == []
|
||||||
|
|
||||||
genre_config = {
|
def test_build_fx_chain_with_args(self):
|
||||||
"mix": {
|
|
||||||
"per_role": {
|
|
||||||
"drums": {
|
|
||||||
"effects": ["Fruity Parametric EQ 2", "Fruity Compressor"],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
plugins = build_fx_chain("drums", genre_config, [])
|
|
||||||
assert len(plugins) == 2
|
|
||||||
# Pro-Q 3 via alias
|
|
||||||
assert plugins[0].name in ("Pro-Q_3", "FabFilter_Pro-Q_3")
|
|
||||||
assert plugins[0].path in ("FabFilter", "FabFilter Pro-Q 3.vst3")
|
|
||||||
# Fruity Compressor → Pro-C 2
|
|
||||||
assert plugins[1].name in ("Pro-C_2", "FabFilter_Pro-C_2")
|
|
||||||
|
|
||||||
def test_build_fx_chain_bass(self):
|
|
||||||
"""build_fx_chain returns PluginDef list for bass role."""
|
|
||||||
from scripts.compose import build_fx_chain
|
from scripts.compose import build_fx_chain
|
||||||
|
assert build_fx_chain("drums", {}, []) == []
|
||||||
genre_config = {
|
|
||||||
"mix": {
|
|
||||||
"per_role": {
|
|
||||||
"bass": {
|
|
||||||
"effects": ["Fruity Parametric EQ 2", "Saturn 2"],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
plugins = build_fx_chain("bass", genre_config, [])
|
|
||||||
assert len(plugins) == 2
|
|
||||||
# Saturn 2 → FabFilter Saturn 2
|
|
||||||
assert "Saturn" in plugins[1].name
|
|
||||||
|
|
||||||
def test_build_fx_chain_empty_effects(self):
|
|
||||||
"""build_fx_chain returns empty list when no effects configured."""
|
|
||||||
from scripts.compose import build_fx_chain
|
|
||||||
|
|
||||||
genre_config = {"mix": {"per_role": {}}}
|
|
||||||
plugins = build_fx_chain("drums", genre_config, [])
|
|
||||||
assert plugins == []
|
|
||||||
|
|
||||||
def test_build_fx_chain_unknown_effect_uses_name(self):
|
|
||||||
"""Unknown effect names are used as-is."""
|
|
||||||
from scripts.compose import build_fx_chain
|
|
||||||
|
|
||||||
genre_config = {
|
|
||||||
"mix": {
|
|
||||||
"per_role": {
|
|
||||||
"lead": {
|
|
||||||
"effects": ["Some Unknown FX"],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
plugins = build_fx_chain("lead", genre_config, [])
|
|
||||||
# Unknown effects are skipped (not added to plugins)
|
|
||||||
assert len(plugins) == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestInstrumentPlugins:
|
|
||||||
"""Test instrument plugin helpers (Serum 2, Omnisphere)."""
|
|
||||||
|
|
||||||
def test_serum2_plugin_def(self):
|
|
||||||
"""serum2() returns PluginDef with registry key name."""
|
|
||||||
from scripts.compose import serum2
|
|
||||||
|
|
||||||
plugin = serum2()
|
|
||||||
assert plugin.name == "Serum2"
|
|
||||||
assert plugin.path == "Serum2.vst3"
|
|
||||||
assert plugin.index == 0
|
|
||||||
|
|
||||||
def test_omnisphere_plugin_def(self):
|
|
||||||
"""omnisphere() returns PluginDef with registry key name."""
|
|
||||||
from scripts.compose import omnisphere
|
|
||||||
|
|
||||||
plugin = omnisphere()
|
|
||||||
assert plugin.name == "Omnisphere"
|
|
||||||
assert plugin.path == "Omnisphere.vst3"
|
|
||||||
assert plugin.index == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreateReturnTracks:
|
class TestCreateReturnTracks:
|
||||||
"""Test create_return_tracks function."""
|
|
||||||
|
|
||||||
def test_create_return_tracks_returns_two(self):
|
def test_create_return_tracks_returns_two(self):
|
||||||
"""create_return_tracks returns [Reverb, Delay] tracks."""
|
|
||||||
from scripts.compose import create_return_tracks
|
from scripts.compose import create_return_tracks
|
||||||
|
|
||||||
tracks = create_return_tracks()
|
tracks = create_return_tracks()
|
||||||
assert len(tracks) == 2
|
assert len(tracks) == 2
|
||||||
assert tracks[0].name == "Reverb"
|
assert tracks[0].name == "Reverb"
|
||||||
assert tracks[1].name == "Delay"
|
assert tracks[1].name == "Delay"
|
||||||
|
|
||||||
def test_reverb_track_has_pro_r2(self):
|
def test_reverb_track_has_pro_r2(self):
|
||||||
"""Reverb return track has FabFilter Pro-R 2 plugin."""
|
|
||||||
from scripts.compose import create_return_tracks
|
from scripts.compose import create_return_tracks
|
||||||
|
|
||||||
tracks = create_return_tracks()
|
tracks = create_return_tracks()
|
||||||
reverb = tracks[0]
|
reverb = tracks[0]
|
||||||
assert len(reverb.plugins) == 1
|
assert len(reverb.plugins) == 1
|
||||||
assert "FabFilter" in reverb.plugins[0].name
|
assert "Pro-R" in reverb.plugins[0].name
|
||||||
assert reverb.plugins[0].path in ("FabFilter", "FabFilter_Pro_R_2.vst3")
|
|
||||||
|
|
||||||
def test_delay_track_has_timeless3(self):
|
def test_delay_track_has_valhalla(self):
|
||||||
"""Delay return track has FabFilter Timeless 3 plugin."""
|
|
||||||
from scripts.compose import create_return_tracks
|
from scripts.compose import create_return_tracks
|
||||||
|
|
||||||
tracks = create_return_tracks()
|
tracks = create_return_tracks()
|
||||||
delay = tracks[1]
|
delay = tracks[1]
|
||||||
assert len(delay.plugins) == 1
|
assert len(delay.plugins) == 1
|
||||||
assert "Timeless" in delay.plugins[0].name
|
assert "Valhalla" in delay.plugins[0].name
|
||||||
assert delay.plugins[0].path in ("FabFilter", "FabFilter_Timeless_3.vst3")
|
|
||||||
|
|
||||||
def test_return_tracks_have_volume_0_7(self):
|
def test_return_tracks_have_volume_0_7(self):
|
||||||
"""Return tracks have volume 0.7."""
|
|
||||||
from scripts.compose import create_return_tracks
|
from scripts.compose import create_return_tracks
|
||||||
|
|
||||||
tracks = create_return_tracks()
|
tracks = create_return_tracks()
|
||||||
for t in tracks:
|
for t in tracks:
|
||||||
assert t.volume == 0.7
|
assert t.volume == 0.7
|
||||||
|
|
||||||
|
|
||||||
|
class TestMusicTheory:
|
||||||
|
def test_parse_key_minor(self):
|
||||||
|
from scripts.compose import parse_key
|
||||||
|
root, minor = parse_key("Am")
|
||||||
|
assert root == "A"
|
||||||
|
assert minor is True
|
||||||
|
|
||||||
|
def test_parse_key_major(self):
|
||||||
|
from scripts.compose import parse_key
|
||||||
|
root, minor = parse_key("C")
|
||||||
|
assert root == "C"
|
||||||
|
assert minor is False
|
||||||
|
|
||||||
|
def test_root_to_midi(self):
|
||||||
|
from scripts.compose import root_to_midi
|
||||||
|
assert root_to_midi("A", 4) == 69
|
||||||
|
assert root_to_midi("C", 4) == 60
|
||||||
|
|
||||||
|
def test_build_chord_major(self):
|
||||||
|
from scripts.compose import build_chord
|
||||||
|
chord = build_chord(60, "major")
|
||||||
|
assert chord == [60, 64, 67]
|
||||||
|
|
||||||
|
def test_build_chord_minor(self):
|
||||||
|
from scripts.compose import build_chord
|
||||||
|
chord = build_chord(60, "minor")
|
||||||
|
assert chord == [60, 63, 67]
|
||||||
|
|
||||||
|
def test_pentatonic_minor(self):
|
||||||
|
from scripts.compose import get_pentatonic
|
||||||
|
notes = get_pentatonic("A", True, 4)
|
||||||
|
assert notes[0] == 69 # A4
|
||||||
|
assert len(notes) == 5
|
||||||
|
|
||||||
|
def test_pentatonic_major(self):
|
||||||
|
from scripts.compose import get_pentatonic
|
||||||
|
notes = get_pentatonic("C", False, 4)
|
||||||
|
assert notes[0] == 60 # C4
|
||||||
|
assert len(notes) == 5
|
||||||
|
|||||||
Reference in New Issue
Block a user