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:
renato97
2026-05-03 19:41:22 -03:00
parent 672607c356
commit a2713abd40
10 changed files with 6234 additions and 912 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+153
View 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()
+428 -505
View File
File diff suppressed because it is too large Load Diff
+336
View 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
+1 -3
View File
@@ -1707,9 +1707,7 @@ class RPPBuilder:
defaults_copy = ["SEL", "1"]
track_elem.append(defaults_copy)
# Track color
if track.color > 0:
track_elem.append(["COLOR", str(track.color)])
# Track color — removed, not recognized by REAPER
# Plugins (FXCHAIN) — wrap VST elements inside proper FXCHAIN structure
if track.plugins:
+199 -174
View File
@@ -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
from pathlib import Path
@@ -9,49 +9,107 @@ sys.path.insert(0, str(Path(__file__).parents[1]))
import pytest
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote
from src.reaper_builder import RPPBuilder
from src.composer.drum_analyzer import DrumLoopAnalysis, Transient, BeatGrid
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def compose_via_builder(
genre: str = "reggaeton",
bpm: float = 95.0,
key: str = "Am",
output_path: str = "output/track.rpp",
) -> SongDefinition:
"""Build a SongDefinition the same way scripts/compose.py does, return it.
This lets us test the compose logic without hitting the filesystem for samples.
"""
import json
from pathlib import Path as P
_ROOT = P(__file__).parent.parent
from src.composer.rhythm import get_notes
from src.composer.melodic import bass_tresillo, lead_hook, chords_block, pad_sustain
from src.composer.converters import rhythm_to_midi, melodic_to_midi
genre_path = _ROOT / "knowledge" / "genres" / f"{genre.lower()}_2009.json"
with open(genre_path, "r", encoding="utf-8") as f:
genre_config = json.load(f)
from scripts.compose import (
build_section_tracks, create_return_tracks, EFFECT_ALIASES,
build_fx_chain, build_sampler_plugin,
def _fake_analysis():
return DrumLoopAnalysis(
file_path="fake_drumloop.wav",
bpm=95.0,
duration=8.0,
beats=[0.0, 0.6316, 1.2632, 1.8947, 2.5263, 3.1579, 3.7895, 4.4211],
transients=[
Transient(time=0.0, type="kick", energy=0.8, spectral_centroid=100),
Transient(time=0.6316, type="hihat", energy=0.4, spectral_centroid=8000),
Transient(time=1.2632, type="snare", energy=0.7, spectral_centroid=3000),
Transient(time=1.8947, type="hihat", energy=0.3, spectral_centroid=7000),
Transient(time=2.5263, type="kick", energy=0.8, spectral_centroid=100),
Transient(time=3.1579, type="snare", energy=0.6, spectral_centroid=3500),
Transient(time=3.7895, type="hihat", energy=0.4, spectral_centroid=9000),
],
beat_grid=BeatGrid(
quarter=[0.0, 0.6316, 1.2632, 1.8947, 2.5263, 3.1579, 3.7895, 4.4211],
eighth=[i * 0.3158 for i in range(16)],
sixteenth=[i * 0.1579 for i in range(32)],
),
key="Am",
key_confidence=0.85,
energy_profile=[0.8, 0.4, 0.7, 0.3, 0.8, 0.6, 0.4, 0.3],
bar_count=2,
sample_rate=44100,
)
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)
return_tracks = create_return_tracks()
def _mock_main(tmp_path, extra_args=None):
output = tmp_path / "track.rpp"
fake_analysis = _fake_analysis()
meta = SongMeta(bpm=bpm, key=key, title=f"{genre.capitalize()} Track")
return SongDefinition(meta=meta, tracks=tracks + return_tracks, sections=sections)
with patch("scripts.compose.SampleSelector") as mock_cls, \
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:
"""Tests for compose workflow producing valid .rpp output."""
def test_compose_produces_rpp_file(self, tmp_path):
"""main() with valid args produces a .rpp file at the output 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
output = _mock_main(tmp_path)
assert output.exists(), f"Expected {output} to exist"
def test_compose_rpp_has_min_6_tracks(self, tmp_path):
"""The .rpp output contains at least 6 <TRACK blocks (roles + 2 returns)."""
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
output = _mock_main(tmp_path)
content = output.read_text(encoding="utf-8")
track_count = content.count("<TRACK")
# 6 roles + 2 return tracks = 8 minimum
assert track_count >= 6, f"Expected >= 6 tracks, got {track_count}"
def test_compose_has_fxchain(self, tmp_path):
"""The .rpp output contains FXCHAIN elements."""
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
output = _mock_main(tmp_path)
content = output.read_text(encoding="utf-8")
assert "FXCHAIN" in content, "Expected FXCHAIN in output"
def test_compose_invalid_bpm_raises(self):
"""main() with bpm=0 raises ValueError."""
from scripts.compose import main
import sys
original_argv = sys.argv
try:
sys.argv = [
"compose",
"--genre", "reggaeton",
"--bpm", "0",
"--key", "Am",
"--output", "output/track.rpp",
]
with pytest.raises(ValueError, match="bpm must be > 0"):
main()
finally:
sys.argv = original_argv
def test_compose_has_midi_source(self, tmp_path):
output = _mock_main(tmp_path)
content = output.read_text(encoding="utf-8")
assert "SOURCE MIDI" in content, "Expected MIDI source in output"
def test_compose_negative_bpm_raises(self):
"""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"):
main()
finally:
sys.argv = original_argv
def test_compose_has_audio_source(self, tmp_path):
output = _mock_main(tmp_path)
content = output.read_text(encoding="utf-8")
assert "SOURCE WAVE" in content, "Expected WAVE source in output"
def test_compose_invalid_bpm_raises(self, tmp_path):
with pytest.raises(ValueError, match="bpm must be > 0"):
_mock_main(tmp_path, ["--bpm", "0"])
def test_compose_negative_bpm_raises(self, tmp_path):
with pytest.raises(ValueError, match="bpm must be > 0"):
_mock_main(tmp_path, ["--bpm", "-10"])
class TestSectionBuilderIntegration:
"""Test section builder integration with SongDefinition."""
class TestDrumloopFirstTracks:
def test_build_section_tracks_returns_tracks_and_sections(self):
"""build_section_tracks returns (tracks, sections) tuple."""
import json
from pathlib import Path as P
def test_all_tracks_created(self, tmp_path):
output = _mock_main(tmp_path)
content = output.read_text(encoding="utf-8")
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
from scripts.compose import build_section_tracks
from src.selector import SampleSelector
def test_clap_on_dembow_beats(self, tmp_path):
from scripts.compose import build_clap_track, SECTIONS
from src.core.schema import SectionDef
genre_path = _ROOT / "knowledge" / "genres" / "reggaeton_2009.json"
with open(genre_path, "r", encoding="utf-8") as f:
genre_config = json.load(f)
sections = [SectionDef(name="chorus", bars=4, energy=1.0)]
offsets = [0.0]
index_path = _ROOT / "data" / "sample_index.json"
selector = SampleSelector(str(index_path))
mock_selector = MagicMock()
mock_selector.select.return_value = [
MagicMock(sample={"original_path": "clap.wav"}),
]
# Pass explicit sections_data since JSON now uses templates format
sections_data = genre_config.get("structure", {}).get("templates", {}).get("extracted_real_tracks", [])
tracks, sections = build_section_tracks(genre_config, selector, "Am", 95.0, sections_data=sections_data)
track = build_clap_track(mock_selector, sections, offsets)
positions = [c.position for c in track.clips]
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"
assert len(sections) > 0, "Expected at least one section"
# Sections should have names from the genre config
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_bass_uses_kick_free_zones(self):
from scripts.compose import build_bass_track
from src.core.schema import SectionDef
def test_song_definition_has_sections_field(self):
"""SongDefinition has a sections field."""
from src.core.schema import SongDefinition, SongMeta, SectionDef
analysis = _fake_analysis()
sections = [SectionDef(name="verse", bars=4, energy=1.0)]
offsets = [0.0]
meta = SongMeta(bpm=95, key="Am")
song = SongDefinition(
meta=meta,
tracks=[],
sections=[SectionDef(name="intro", bars=4, energy=0.3)],
track = build_bass_track(analysis, sections, offsets, "A", True)
assert len(track.clips) > 0, "Bass should have clips"
assert all(n.duration == 0.5 for n in track.clips[0].midi_notes), "Bass notes should be 0.5 beats"
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 song.sections[0].name == "intro"
assert callable(build_section_tracks)
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
View 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"
+41 -88
View File
@@ -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
from pathlib import Path
@@ -7,121 +11,70 @@ sys.path.insert(0, str(Path(__file__).parents[1]))
import pytest
import argparse
from unittest.mock import patch, MagicMock
from scripts.compose import main as compose_main
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):
"""Without --render, the render flag should be False."""
parser = argparse.ArgumentParser()
parser.add_argument("--render", action="store_true")
parser.add_argument("--render-output", default=None)
args = parser.parse_args([])
assert args.render is False
def test_render_flag_true_when_provided(self):
"""With --render, the flag should be True."""
parser = argparse.ArgumentParser()
parser.add_argument("--render", action="store_true")
parser.add_argument("--render-output", default=None)
args = parser.parse_args(["--render"])
assert args.render is True
def test_render_output_defaults_to_none(self):
"""--render-output defaults to None when not provided."""
parser = argparse.ArgumentParser()
parser.add_argument("--render", action="store_true")
parser.add_argument("--render-output", default=None)
args = parser.parse_args([])
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:
mock_sel_instance = MagicMock()
mock_sel_instance.select_one.return_value = None
mock_selector.return_value = mock_sel_instance
class TestComposeNoRender:
"""Verify the drumloop-first compose.py main() produces output without --render."""
with patch("sys.argv", ["compose.py", "--genre", "reggaeton", "--render"]):
compose_main()
def test_main_without_render_produces_rpp(self, tmp_path):
from unittest.mock import patch, MagicMock
from src.composer.drum_analyzer import DrumLoopAnalysis, Transient, BeatGrid
mock_render.assert_called_once()
call_args = mock_render.call_args
# First arg should be the .rpp path, second should be .wav path
rpp_path = call_args[0][0]
wav_path = call_args[0][1]
assert rpp_path.endswith(".rpp")
assert wav_path.endswith(".wav")
output = tmp_path / "track.rpp"
fake_analysis = DrumLoopAnalysis(
file_path="f.wav", bpm=95.0, duration=8.0,
beats=[0.0, 0.6316, 1.2632, 1.8947],
transients=[Transient(time=0.0, type="kick", energy=0.8, spectral_centroid=100)],
beat_grid=BeatGrid(quarter=[0.0, 0.6316], eighth=[], sixteenth=[]),
key="Am", key_confidence=0.8, energy_profile=[0.5], bar_count=1,
)
@patch("scripts.compose.render_project")
@patch("scripts.compose.RPPBuilder")
def test_without_render_does_not_call_render_project(self, mock_builder_cls, mock_render):
"""Calling main without --render does NOT invoke render_project."""
mock_builder = MagicMock()
mock_builder_cls.return_value = mock_builder
with patch("scripts.compose.SampleSelector") as mock_cls, \
patch("scripts.compose.DrumLoopAnalyzer") as mock_a_cls:
mock_sel = MagicMock()
mock_sel._samples = [
{"role": "drumloop", "perceptual": {"tempo": 95.0}, "musical": {"key": "Am"},
"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:
mock_sel_instance = MagicMock()
mock_sel_instance.select_one.return_value = None
mock_selector.return_value = mock_sel_instance
from scripts.compose import main
orig = sys.argv
try:
sys.argv = ["compose", "--output", str(output)]
main()
finally:
sys.argv = orig
with patch("sys.argv", ["compose.py", "--genre", "reggaeton"]):
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()
assert output.exists()
+79 -142
View File
@@ -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
from pathlib import Path
@@ -10,200 +10,137 @@ from src.core.schema import SectionDef, PluginDef
class TestSectionDef:
"""Test SectionDef dataclass."""
def test_section_def_instantiation(self):
"""SectionDef creates with name, bars, energy."""
section = SectionDef(name="chorus", bars=8, energy=0.9)
assert section.name == "chorus"
assert section.bars == 8
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.vol_mult == 1.0
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)
assert section.energy == 0.5
assert section.velocity_mult == 1.0
assert section.vol_mult == 1.0
def test_section_def_custom_mults(self):
"""SectionDef accepts custom velocity_mult and vol_mult via __init__ args."""
section = SectionDef(
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.vol_mult == 0.6
class TestVST3Effects:
"""Test VST3 premium plugin mappings."""
class TestPluginRegistry:
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):
"""Fruity Parametric EQ 2 → FabFilter Pro-Q 3 via normalization."""
from scripts.compose import _VST3_EFFECTS
# Fruity Parametric EQ 2 normalizes to Pro-Q 3
registry_key, filename = _VST3_EFFECTS["Pro-Q 3"]
assert registry_key == "Pro-Q_3"
assert filename == "FabFilter"
class TestMakePlugin:
def test_make_plugin_known_key(self):
from scripts.compose import make_plugin
p = make_plugin("Decapitator", 0)
assert p.name == "Decapitator"
assert p.index == 0
def test_fruity_compressor_maps_to_proc2(self):
"""Fruity Compressor → FabFilter Pro-C 2 via normalization."""
from scripts.compose import _VST3_EFFECTS
registry_key, filename = _VST3_EFFECTS["Pro-C 2"]
assert registry_key == "Pro-C_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
def test_make_plugin_unknown_key(self):
from scripts.compose import make_plugin
p = make_plugin("NonExistent", 2)
assert p.name == "NonExistent"
assert p.index == 2
class TestBuildFxChain:
"""Test build_fx_chain function."""
def test_build_fx_chain_drums(self):
"""build_fx_chain returns PluginDef list for drums role."""
def test_build_fx_chain_returns_list(self):
from scripts.compose import build_fx_chain
assert build_fx_chain() == []
genre_config = {
"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."""
def test_build_fx_chain_with_args(self):
from scripts.compose import build_fx_chain
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
assert build_fx_chain("drums", {}, []) == []
class TestCreateReturnTracks:
"""Test create_return_tracks function."""
def test_create_return_tracks_returns_two(self):
"""create_return_tracks returns [Reverb, Delay] tracks."""
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"
def test_reverb_track_has_pro_r2(self):
"""Reverb return track has FabFilter Pro-R 2 plugin."""
from scripts.compose import create_return_tracks
tracks = create_return_tracks()
reverb = tracks[0]
assert len(reverb.plugins) == 1
assert "FabFilter" in reverb.plugins[0].name
assert reverb.plugins[0].path in ("FabFilter", "FabFilter_Pro_R_2.vst3")
assert "Pro-R" in reverb.plugins[0].name
def test_delay_track_has_timeless3(self):
"""Delay return track has FabFilter Timeless 3 plugin."""
def test_delay_track_has_valhalla(self):
from scripts.compose import create_return_tracks
tracks = create_return_tracks()
delay = tracks[1]
assert len(delay.plugins) == 1
assert "Timeless" in delay.plugins[0].name
assert delay.plugins[0].path in ("FabFilter", "FabFilter_Timeless_3.vst3")
assert "Valhalla" in delay.plugins[0].name
def test_return_tracks_have_volume_0_7(self):
"""Return tracks have volume 0.7."""
from scripts.compose import create_return_tracks
tracks = create_return_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