# 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 blocks ├── regex for audio paths → verify exist ├── regex 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 opening blocks track_count = sum(1 for line in lines if line.strip() == " 9: errors.append(f"Expected 9 tracks, got {track_count} (possible duplicate)") # 2. Audio clip paths — verify paths exist import re wave_pattern = re.compile(r']*>[^]*?', content, re.DOTALL) for item in midi_items: if "]*>\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']*>[^]*?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>))*?(?=|$)', content, re.DOTALL) for track in track_blocks: track_name_match = re.search(r' 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("\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.