feat: SDD workflow — test sync, song generation + validation, ReaScript hybrid pipeline

- compose-test-sync: fix 3 failing tests (NOTE_TO_MIDI, DrumLoopAnalyzer mock, section name)
- generate-song: CLI wrapper + RPP validator (6 structural checks) + 4 e2e tests
- reascript-hybrid: ReaScriptGenerator + command protocol + CLI + 16 unit tests
- 110/110 tests passing
- Full SDD cycle (propose→spec→design→tasks→apply→verify) for all 3 changes
This commit is contained in:
renato97
2026-05-03 22:00:26 -03:00
parent 7729d5f12f
commit 48bc271afc
25 changed files with 2842 additions and 343 deletions

View File

@@ -0,0 +1,262 @@
# Design: generate-song CLI
## Technical Approach
A thin CLI wrapper (`scripts/generate.py`) that delegates to the existing `compose.py` `main()`, plus a text-based `.rpp` validator (`src/validator/rpp_validator.py`). No new architecture layers — reuse compose pipeline, add thin orchestration on top.
## Architecture Decisions
### Decision: Thin CLI wrapper (not a fork)
**Choice**: `generate.py` calls `compose.main()` directly as a function import, not as a subprocess.
**Alternatives considered**: Subprocess spawn (`subprocess.run([sys.executable, "scripts/compose.py", ...])`), or duplicating compose logic in generate.py.
**Rationale**: Subprocess adds process overhead and requires serializing args. Duplication violates DRY. Direct function call is clean and allows `--validate` to call `validate_rpp_output()` on the in-memory result before writing to disk.
### Decision: Text-based RPP validation (not full parser)
**Choice**: Validate `.rpp` output using regex/string search on the raw file text.
**Alternatives considered**: Full RPP parser (complex, out of scope), or relying on `SongDefinition.validate()` only.
**Rationale**: RPP files are text-format. For structural validation (track count, audio paths, MIDI notes, send routing, plugin chains), regex over the raw text is sufficient and avoids building a complete parser. The validator is not a schema validator — it checks output invariants only.
### Decision: `validate_rpp_output()` as reusable module (not inline in generate.py)
**Choice**: `src/validator/rpp_validator.py` with a single exported function `validate_rpp_output(rpp_path: str) -> list[str]`.
**Alternatives considered**: Inline validation in `generate.py`, or a class-based validator.
**Rationale**: Reusability — other tools (tests, scripts, future CLI variants) can call the validator without running generation. Single function is simplest API.
### Decision: Perc loop fallback in compose.py (no change to DRUMLOOP_FILES)
**Choice**: `build_perc_track()` in `compose.py` already falls back when `91bpm bellako percloop.wav` is absent — it silently skips missing files (see compose.py line 282: `if perc_path.exists()`).
**Alternatives considered**: Add a fallback dict lookup in generate.py, or modify DRUMLOOP_FILES.
**Rationale**: The spec requires "skip silently rather than crash" — this behavior already exists in `build_perc_track()`. No changes needed to compose.py.
## Data Flow
```
generate.py main()
├── argparse (--bpm, --key, --output, --seed, --validate)
├── import compose.main
│ └── compose.main(args) → writes output/rpp
└── if --validate:
└── validate_rpp_output(output_path) → list[str]
├── count <TRACK> blocks
├── regex <SOURCE WAVE> for audio paths → verify exist
├── regex <NOTE for MIDI clip notes
├── compute arrangement end beat
├── regex AUXRECV for send routing
└── count VST/FXCHAIN for plugin chains
```
## File Changes
| File | Action | Description |
|------|--------|-------------|
| `scripts/generate.py` | Create | CLI entry point, imports and calls `compose.main()` |
| `src/validator/rpp_validator.py` | Create | `validate_rpp_output()` — text-based RPP structural validator |
| `tests/test_generate_song.py` | Create | E2E smoke test + validation + reproducibility |
## Interfaces / Contracts
### `scripts/generate.py`
```python
# scripts/generate.py
import argparse, sys
from pathlib import Path
_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(_ROOT))
import compose
def main():
parser = argparse.ArgumentParser(description="Generate a REAPER .rpp song.")
parser.add_argument("--bpm", type=float, default=95)
parser.add_argument("--key", default="Am")
parser.add_argument("--output", default="output/song.rpp")
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--validate", action="store_true")
args = parser.parse_args()
if args.bpm <= 0:
raise ValueError("bpm must be > 0")
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
# Delegate to compose.main
sys.argv = [
sys.argv[0],
"--bpm", str(args.bpm),
"--key", args.key,
"--output", str(output_path),
"--seed", str(args.seed),
]
compose.main()
if args.validate:
from src.validator.rpp_validator import validate_rpp_output
errors = validate_rpp_output(str(output_path))
if errors:
print("Validation errors:", file=sys.stderr)
for e in errors:
print(f" - {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
```
### `src/validator/rpp_validator.py`
```python
# src/validator/rpp_validator.py
"""Text-based .rpp structural validator.
Validates: track count, audio clip paths, MIDI note presence,
arrangement duration, send routing, and plugin chain presence.
"""
from pathlib import Path
def validate_rpp_output(rpp_path: str) -> list[str]:
"""Validate a generated .rpp file. Returns list of error strings."""
errors = []
content = Path(rpp_path).read_text(encoding="utf-8")
lines = content.split("\n")
# 1. Track count — count <TRACK> opening blocks
track_count = sum(1 for line in lines if line.strip() == "<TRACK")
if track_count < 9:
errors.append(f"Expected 9 tracks, got {track_count}")
elif track_count > 9:
errors.append(f"Expected 9 tracks, got {track_count} (possible duplicate)")
# 2. Audio clip paths — verify <SOURCE WAVE "..."> paths exist
import re
wave_pattern = re.compile(r'<SOURCE WAVE "([^"]+)"')
for match in wave_pattern.finditer(content):
path = match.group(1)
if not Path(path).exists():
errors.append(f"Audio clip path does not exist: {path}")
# 3. MIDI clips have notes — look for NOTE events in MIDI items
midi_items = re.findall(r'<ITEM[^>]*>[^]*?</ITEM>', content, re.DOTALL)
for item in midi_items:
if "<SOURCE MIDI" in item:
# Count NOTE entries in this MIDI item
note_count = len(re.findall(r'NOTE \d+ \d+ \d+', item))
if note_count == 0:
errors.append("MIDI clip has no notes")
# 4. Arrangement duration — find last item position + length
item_positions = re.findall(r'<ITEM[^>]*>\s*POSITION (\d+\.?\d*)', content)
if item_positions:
last_pos = max(float(p) for p in item_positions)
# Also get lengths to find actual end
item_lengths = re.findall(r'<ITEM[^>]*>[^]*?LENGTH (\d+\.?\d*)', content, re.DOTALL)
if item_lengths:
ends = [float(p) + float(l) for p, l in zip(item_positions, item_lengths)]
max_end = max(ends)
expected_beats = 52 * 4 # 52 bars at 4 beats/bar
if max_end < expected_beats:
errors.append(f"Arrangement ends at beat {max_end}, expected at least {expected_beats}")
# 5. Send routing — each non-return track should have AUXRECV
# Identify return tracks (have RECVFX but no MAINSEND)
track_blocks = re.findall(r'<TRACK>(?:[^<]|<(?!TRACK>))*?(?=<TRACK>|$)', content, re.DOTALL)
for track in track_blocks:
track_name_match = re.search(r'<NAME "([^"]+)"', track)
if not track_name_match:
continue
name = track_name_match.group(1)
# Skip return tracks
if name in ("Reverb", "Delay"):
continue
# Non-return tracks need AUXRECV
if "AUXRECV" not in track:
errors.append(f"Track '{name}' missing send to return track")
# 6. Plugin chains — each track should have FXCHAIN with VST entries
for track in track_blocks:
name_match = re.search(r'<NAME "([^"]+)"', track)
if not name_match:
continue
name = name_match.group(1)
if name in ("Reverb", "Delay"):
continue # Return tracks have different structure
if "<FXCHAIN" not in track:
errors.append(f"Track '{name}' missing FXCHAIN")
elif not re.search(r'VST\d?', track):
errors.append(f"Track '{name}' missing VST plugin in FXCHAIN")
return errors
```
### `tests/test_generate_song.py`
```python
# tests/test_generate_song.py
import subprocess, sys, tempfile
from pathlib import Path
import pytest
def test_generate_cli_smoke(tmp_path):
output = tmp_path / "song.rpp"
result = subprocess.run(
[sys.executable, "scripts/generate.py",
"--bpm", "95", "--key", "Am",
"--output", str(output), "--seed", "42"],
capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert output.exists()
assert output.stat().st_size > 0
def test_validate_passes_for_valid_output(tmp_path):
output = tmp_path / "song.rpp"
subprocess.run(
[sys.executable, "scripts/generate.py",
"--bpm", "95", "--key", "Am",
"--output", str(output), "--seed", "42", "--validate"],
check=True
)
from src.validator.rpp_validator import validate_rpp_output
errors = validate_rpp_output(str(output))
assert errors == [], f"Unexpected errors: {errors}"
def test_validate_detects_track_count_violation(tmp_path, monkeypatch):
# Write a malformed rpp with only 5 tracks
rpp_path = tmp_path / "bad.rpp"
rpp_path.write_text("<TRACK>\n" * 5, encoding="utf-8")
from src.validator.rpp_validator import validate_rpp_output
errors = validate_rpp_output(str(rpp_path))
assert any("Expected 9 tracks" in e for e in errors)
def test_reproducibility_same_seed(tmp_path):
output_a = tmp_path / "song_a.rpp"
output_b = tmp_path / "song_b.rpp"
for out in (output_a, output_b):
subprocess.run(
[sys.executable, "scripts/generate.py",
"--bpm", "95", "--key", "Am",
"--output", str(out), "--seed", "42"],
check=True
)
assert output_a.read_bytes() == output_b.read_bytes()
```
## Testing Strategy
| Layer | What to Test | Approach |
|-------|-------------|----------|
| Unit | `validate_rpp_output()` correctness | Synthesize malformed .rpp strings, check error detection |
| Integration | CLI + compose end-to-end | `subprocess.run` with `--seed 42`, assert file written |
| E2E | Reproducibility + validation | Two runs with same seed → byte-identical output |
## Migration / Rollout
No migration required — this is a net-new feature (new files only). No breaking changes to existing compose.py or reaper_builder.
## Open Questions
- None — all decisions resolved in spec or above.

View File

@@ -0,0 +1,126 @@
# Delta: generate-song
## ADDED Requirements
### Requirement: CLI generates REAPER .rpp from arguments
`scripts/generate.py` MUST accept `--bpm`, `--key`, `--output`, and `--seed` arguments.
When invoked as `python scripts/generate.py --bpm 95 --key Am --output output/song.rpp --seed 42`,
the script SHALL produce a valid REAPER .rpp file at the specified output path using seed 42 for all random choices.
#### Scenario: Happy path — produces 52-bar arrangement
- GIVEN no arguments beyond required ones; `seed=42` is the default
- WHEN the CLI is invoked with `--bpm 95 --key Am --output /tmp/song.rpp --seed 42`
- THEN a .rpp file SHALL be written containing 9 tracks (7 normal + 2 return)
- AND the arrangement duration SHALL equal 52 bars at 95 BPM
- AND all 19 plugins (across tracks, returns, master) SHALL be present in the FX chains
#### Scenario: Default seed produces reproducible output
- GIVEN two invocations with identical arguments (including `--seed 42`)
- WHEN both are run in the same environment
- THEN the resulting .rpp files SHALL be byte-for-byte identical
#### Scenario: Invalid BPM raises ValueError
- GIVEN `--bpm 0` or `--bpm -10`
- WHEN the CLI is invoked
- THEN a `ValueError` SHALL be raised with message matching `bpm must be > 0`
---
### Requirement: `validate_rpp_output(rpp_path) -> list[str]` checks all structural invariants
The validation function MUST return an empty list for a valid .rpp, and a list of error strings for any violation.
#### Scenario: Returns empty list for valid output
- GIVEN a .rpp produced by the CLI with all required structure
- WHEN `validate_rpp_output(path)` is called
- THEN the result SHALL be `[]`
#### Scenario: Detects wrong track count
- GIVEN a .rpp with fewer than 9 tracks
- WHEN `validate_rpp_output(path)` is called
- THEN the returned list SHALL include `"Expected 9 tracks, got N"`
#### Scenario: Detects missing plugin chains
- GIVEN a .rpp where a track is missing its FXCHAIN block
- WHEN `validate_rpp_output(path)` is called
- THEN the returned list SHALL include `"Track 'X' missing FXCHAIN"`
#### Scenario: Detects broken audio clip paths
- GIVEN a .rpp containing an audio clip whose `SOURCE WAVE` path does not exist on disk
- WHEN `validate_rpp_output(path)` is called
- THEN the returned list SHALL include `"Audio clip path does not exist: /path/to/file.wav"`
#### Scenario: Detects MIDI clips without notes
- GIVEN a .rpp containing a MIDI clip with zero notes
- WHEN `validate_rpp_output(path)` is called
- THEN the returned list SHALL include `"MIDI clip has no notes"`
#### Scenario: Detects incorrect arrangement duration
- GIVEN a .rpp whose final item ends before the expected 52-bar duration at the given BPM
- WHEN `validate_rpp_output(path)` is called
- THEN the returned list SHALL include `"Arrangement ends at beat X, expected at least Y"`
#### Scenario: Detects missing send routing
- GIVEN a .rpp where a non-return track has no `AUXRECV` send to a return track
- WHEN `validate_rpp_output(path)` is called
- THEN the returned list SHALL include `"Track 'X' missing send to return track"`
---
### Requirement: `scripts/generate.py` uses REAPER drumloops with fallback
The script SHALL pick drumloop files from the Ableton drumloop directory, cycling through available files per variant, and SHALL fall back gracefully when a file is absent.
#### Scenario: Picks from seco and filtrado variants
- GIVEN the CLI is run with `--seed 42`
- WHEN the resulting .rpp is inspected
- THEN audio clips SHALL reference files from the `seco` and `filtrado` pools as defined in `DRUMLOOP_FILES`
- AND the variant per section SHALL follow `DRUMLOOP_ASSIGNMENTS`
#### Scenario: Falls back when perc loop file is missing
- GIVEN the file `91bpm bellako percloop.wav` does not exist
- WHEN `build_perc_track` is called for a verse or chorus section
- THEN no Perc clip SHALL be added for that section (skip silently rather than crash)
---
### Requirement: Test suite for generate-song
A test file at `tests/test_generate_song.py` MUST cover the CLI and validation function.
#### Scenario: CLI end-to-end smoke test
- GIVEN `tmp_path` fixture
- WHEN `python scripts/generate.py --bpm 95 --key Am --output {tmp_path}/song.rpp --seed 42` is executed as a subprocess
- THEN the resulting file SHALL exist and be non-empty
#### Scenario: Validation passes for valid output
- GIVEN a generated .rpp at a known path
- WHEN `validate_rpp_output(path)` is called
- THEN it SHALL return `[]`
#### Scenario: Validation detects track count violation
- GIVEN a .rpp with 5 tracks (not 9)
- WHEN `validate_rpp_output(path)` is called
- THEN the error list SHALL contain a track-count violation message
#### Scenario: Reproducibility — same seed gives same output
- GIVEN two temp paths
- WHEN the CLI is run with `--seed 42` to both paths
- THEN both output files SHALL have identical content

View File

@@ -0,0 +1,31 @@
# Tasks: generate-song CLI
## Phase 1: Foundation — RPP Validator
- [x] 1.1 Create `src/validator/rpp_validator.py` with `validate_rpp_output(rpp_path: str) -> list[str]`
- [x] 1.2 Implement track count check — count `<TRACK` blocks, assert == 9
- [x] 1.3 Implement audio path check — regex `<SOURCE WAVE "([^"]+)"`, verify files exist
- [x] 1.4 Implement MIDI note presence check — find `<ITEM>` blocks with `<SOURCE MIDI>`, count `NOTE \d+ \d+ \d+` entries
- [x] 1.5 Implement arrangement duration check — find last item position + length, assert >= 52 bars × 4 beats
- [x] 1.6 Implement send routing check — each non-return track must have `AUXRECV`
- [x] 1.7 Implement plugin chain check — each non-return track must have `<FXCHAIN>` with `VST` entry
## Phase 2: Core Implementation — CLI Wrapper
- [x] 2.1 Create `scripts/generate.py` with argparse: `--bpm` (float, default 95), `--key` (default "Am"), `--output` (default "output/song.rpp"), `--seed` (int, default 42), `--validate` (flag)
- [x] 2.2 Add BPM validation — raise `ValueError` if `bpm <= 0`
- [x] 2.3 Add output directory creation — `output_path.parent.mkdir(parents=True, exist_ok=True)`
- [x] 2.4 Wire `compose.main()` — set `sys.argv` and call `compose.main()` directly (not subprocess)
- [x] 2.5 Wire `--validate` flag — call `validate_rpp_output()` and `sys.exit(1)` on errors
## Phase 3: Testing
- [x] 3.1 Write `tests/test_generate_song.py::test_generate_cli_smoke` — subprocess call, assert returncode 0, file exists, size > 0
- [x] 3.2 Write `tests/test_generate_song.py::test_validate_passes_for_valid_output` — CLI with `--validate`, assert `errors == []`
- [x] 3.3 Write `tests/test_generate_song.py::test_validate_detects_track_count_violation` — synthesize 5-track .rpp, assert "Expected 9" in errors
- [x] 3.4 Write `tests/test_generate_song.py::test_reproducibility_same_seed` — run CLI twice with `--seed 42`, assert byte-identical output
## Phase 4: Verification
- [x] 4.1 Run full test suite — `pytest tests/` — verify all 90+ tests pass (existing + new)
- [x] 4.2 Run new tests in isolation — `pytest tests/test_generate_song.py -v` — confirm 4 tests pass