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,112 @@
# Archive: reascript-hybrid
**Archived**: 2026-05-03
**Status**: Complete — 110 tests pass (verify PASS)
---
## Summary
Added a Phase 2 pipeline that runs inside REAPER via ReaScript for FX verification, track calibration, audio rendering, and loudness measurement. Phase 1 (.rpp generation via RPPBuilder) is unchanged and works standalone. The two-phase architecture enables offline composition with in-DAW verification.
---
## Specs Synced
| Domain | Action | Details |
|--------|--------|---------|
| reascript-generator | Created | ReaScriptGenerator class, command protocol, ReaScript API subset, Phase 2 pipeline steps |
New `reascript-generator` domain capability added to the system.
---
## Files Changed
| File | Change | Description |
|------|--------|-------------|
| `src/reaper_scripting/__init__.py` | Created | `ReaScriptGenerator` class — generates self-contained Python ReaScript files |
| `src/reaper_scripting/commands.py` | Created | `ReaScriptCommand`, `ReaScriptResult` dataclasses + `write_command()`, `read_result()`, `ProtocolVersionError` |
| `scripts/run_in_reaper.py` | Created | CLI entry point for Phase 2: generate script → write command JSON → poll result → print LUFS |
| `tests/test_reaper_scripting.py` | Created | 16 unit tests (command protocol, generator output, error handling) |
---
## Tasks Completed
All 3 implementation phases per `tasks.md` — all complete. Phase 4 (integration test against live REAPER) marked manual/skipped.
**Phase 1 — Protocol Layer (1.11.4)**: `commands.py` with `ReaScriptCommand`, `ReaScriptResult`, `write_command()`, `read_result()`, `ProtocolVersionError`.
**Phase 2 — ReaScript Generator (2.12.8)**: `ReaScriptGenerator` in `__init__.py` generating self-contained Python ReaScript with hand-rolled JSON parser, API availability check, full Phase 2 pipeline, and error handling.
**Phase 3 — CLI Orchestration (3.13.7)**: `run_in_reaper.py` with argparse CLI, script path resolution via REAPER ResourcePath, command/result JSON round-trip, timeout handling, LUFS output files.
**Phase 4 — Integration Test (4.14.4)**: Manual testing against live REAPER — skipped in CI.
---
## Verification
- **Test result**: 110 tests pass (`pytest tests/ -q`)
- **New tests**: 16/16 pass (`pytest tests/test_reaper_scripting.py -v`)
- **Implementation**: `ReaScriptGenerator.generate()` writes valid Python; protocol round-trips correctly; `ProtocolVersionError` raised on version mismatch
---
## Architecture Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| JSON file protocol over python-reapy | `fl_control_command.json` / `fl_control_result.json` in REAPER ResourcePath | No network dependency; REAPER owns timing; JSON is human-readable for debugging |
| Self-contained ReaScript (no `import json`) | Hand-rolled JSON parser via string splitting (~20 lines) | Maximum REAPER version compatibility; avoids import-time failures |
| Separate `commands.py` for protocol | `ReaScriptCommand`, `ReaScriptResult` isolated from generator | Protocol is stable and testable in isolation |
| `track_calibration` JSON array | Stateless interface for volume/pan/sends per track | Retry-friendly; command JSON valid for replay if REAPER crashes mid-calibration |
---
## ReaScript Hybrid Pipeline
```
Phase 1 (offline, Python) Phase 2 (inside REAPER, ReaScript)
───────────────────────── ─────────────────────────────────
RPPBuilder.build() Main_openProject(rpp_path)
│ │
▼ ▼
output/song.rpp TrackFX_GetCount + TrackFX_GetFXName
→ fx_errors (missing plugins)
│ │
│ SetMediaTrackInfo_Value(VOLUME/PAN)
│ CreateTrackSend for each send
│ │
│ ▼
│ Main_RenderFile → output/song.wav
│ │
│ ▼
│ CalcMediaSrcLoudness
│ → integrated_lufs, short_term_lufs
│ │
│ ▼
│ write result.json
run_in_reaper.py
→ generate phase2.py
→ write command.json ──────────────────────────────►
→ poll result.json ◄──────────────────────────────
→ print LUFS, write fx_errors.json
```
---
## Archive Contents
- `proposal.md`
- `spec.md`
- `design.md`
- `tasks.md` ✅ (11/12 tasks complete — Phase 4 manual)
---
## SDD Cycle Complete
The change has been fully planned, implemented, verified, and archived. Ready for the next change.

View File

@@ -0,0 +1,130 @@
# Design: reascript-hybrid
## Technical Approach
Phase 2 runs inside REAPER via a self-contained Python ReaScript. Our Python generates the ReaScript file and drives it via a JSON file protocol — no network, no distant API. REAPER controls timing; we just poll for the result.
## Architecture Decisions
### Decision: JSON file protocol over python-reapy
**Choice**: JSON files via `fl_control_command.json` / `fl_control_result.json` in REAPER's ResourcePath
**Alternatives considered**: python-reapy (network/WebSocket, REAPER distant API)
**Rationale**: No network dependency. REAPER owns the timing — avoids race conditions when REAPER is busy. Simpler debugging: JSON is readable in any editor.
### Decision: Self-contained ReaScript with no external imports
**Choice**: Generated ReaScript uses only the built-in ReaScript API (no `import json` — use `os` and string manipulation)
**Alternatives considered**: Importing Python's `json` module via Python 3.x ReaScript support
**Rationale**: Maximum compatibility across REAPER versions. JSON parsing via hand-rolled parser is ~20 lines of string splitting. Avoids any import-time failures.
### Decision: Separate commands.py for protocol testability
**Choice**: `commands.py` exposes `read_command`, `write_result`, `ReaScriptCommand`, `ReaScriptResult`
**Alternatives considered**: Protocol classes in `__init__.py`
**Rationale**: Unit test the protocol without instantiating ReaScriptGenerator or touching REAPER. The protocol is stable and worth isolating.
### Decision: Track calibration via JSON array, not direct API calls from Python
**Choice**: `track_calibration` list in command JSON describes volume/pan/sends per track
**Alternatives considered**: Python calls REAPER API directly for each calibration step
**Rationale**: Keeps the interface stateless and retry-friendly. If REAPER crashes mid-calibration, the command JSON is still valid for replay.
## Data Flow
```
scripts/run_in_reaper.py src/reaper_scripting/ REAPER
│ │ │
│ generate(cmd) │ │
│──────────────────────────> ReaScriptGenerator │
│ │ generates .py │
│ write_command(cmd.json) │ │
│────────────────────────────>│ │
│ │ write to ResourcePath() │
│ │────────────────────────>│
│ │ │ Action triggered
│ │ │ reads command.json
│ │ │ executes pipeline
│ │ │ writes result.json
│ │<─────────────────────────│
│ read_result() │ │
│<─────────────────────────────│ │
```
## File Changes
| File | Action | Description |
|------|--------|-------------|
| `src/reaper_scripting/__init__.py` | Create | `ReaScriptGenerator.generate(path, cmd)` — writes self-contained ReaScript |
| `src/reaper_scripting/commands.py` | Create | `ReaScriptCommand`, `ReaScriptResult` dataclasses + `write_command()`, `read_result()` |
| `scripts/run_in_reaper.py` | Create | CLI: generate script → write command JSON → poll result → print LUFS |
## Interface Contracts
### ReaScriptGenerator
```python
class ReaScriptGenerator:
def generate(self, path: Path, command: ReaScriptCommand) -> None:
"""Write a self-contained ReaScript .py to path."""
```
The generated script reads `fl_control_command.json`, runs the pipeline, writes `fl_control_result.json`.
### Command JSON schema (`fl_control_command.json`)
```json
{
"version": 1,
"action": "calibrate" | "verify_fx" | "render",
"rpp_path": "absolute path",
"render_path": "absolute path for WAV output",
"timeout": 120,
"track_calibration": [
{
"track_index": 0,
"volume": 0.85,
"pan": 0.0,
"sends": [{"dest_track_index": 5, "level": 0.05}]
}
]
}
```
### Result JSON schema (`fl_control_result.json`)
```json
{
"version": 1,
"status": "ok" | "error" | "timeout",
"message": "",
"lufs": -14.2,
"integrated_lufs": -14.2,
"short_term_lufs": -12.1,
"fx_errors": [{"track_index": 2, "fx_index": 1, "name": "", "expected": "Serum_2"}],
"tracks_verified": 8
}
```
## Phase 2 Pipeline (ReaScript)
1. `GetFunctionMetadata` — verify API availability
2. `Main_openProject(rpp_path)` — load .rpp
3. Iterate tracks: `TrackFX_GetCount` + `TrackFX_GetFXName` per slot → collect `fx_errors`
4. For each `track_calibration` entry: `SetMediaTrackInfo_Value(VOLUME/PAN)` + `CreateTrackSend`
5. `Main_RenderFile` → render to `render_path`
6. `CalcMediaSrcLoudness(render_path)` → extract `integrated_lufs`, `short_term_lufs`
7. Write result JSON
## Testing Strategy
| Layer | What | How |
|-------|------|-----|
| Unit | `ReaScriptCommand`/`ReaScriptResult` JSON round-trip | `pytest tests/test_commands.py` — serialize/deserialize, version mismatch raises `ProtocolVersionError` |
| Unit | ReaScriptGenerator output is valid Python | `pytest tests/test_reagenerator.py` — parse generated script with `ast.parse`, check it contains required API calls |
| Integration | Full pipeline with REAPER | `pytest tests/test_phase2.py -k integration` — skipped in CI, runs against live REAPER |
## Open Questions
- [ ] Should `render_path` default to the .rpp's folder with `_rendered.wav` suffix?
- [ ] Do we need to handle REAPER's `__startup__.py` registration automatically, or is manual Action registration acceptable for Phase 1?

View File

@@ -0,0 +1,102 @@
# Proposal: reascript-hybrid
## Intent
The .rpp generator (RPPBuilder) works offline but has a hard ceiling: no verification that FX loaded in REAPER, no rendering, no loudness validation. This change adds a **Phase 2** that runs inside REAPER via ReaScript, enabling FX verification, precise mix calibration, rendering, and output validation — while keeping Phase 1 offline and composable.
## Scope
### In Scope
- ReaScript generator that opens a .rpp and refines it via REAPER's native API
- FX chain verification (confirm plugins loaded, report failures)
- Track calibration (volume, pan, sends per track)
- Audio rendering to file (using RenderProject)
- Loudness validation (CalcMediaSrcLoudness)
- New module: `src/reaper_scripting/__init__.py` — ReaScript generator
- New script: `scripts/run_in_reaper.py` — CLI to trigger Phase 2
### Out of Scope
- Phase 1 changes (compose.py, RPPBuilder already work)
- REAPER automation via OSC/HTTP (not needed yet)
- Multi-DAW support
## Capabilities
> This change introduces a new spec capability. The contract with sdd-spec will define exact function signatures and error handling.
- `reascript-generator`: Generates Python ReaScript that runs inside REAPER to verify/mix/render .rpp projects
## Approach
### Bridge: ReaScript File + Action Trigger (Option B from discovery)
1. Our Python generates a self-contained ReaScript `.py` file to a watched folder
2. REAPER runs it via a custom Action (assigned via `__startup__.py` or manually)
3. ReaScript reads/writes state via a JSON command file for two-way communication
4. Our external Python polls/waits for completion
**Why not python-reapy?** It requires REAPER running with distant API enabled and adds a network dependency. Option B is more robust: REAPER controls timing, our script is a dumb generator.
### Two-way Communication
```
Our Python REAPER (ReaScript)
│ │
│--- write command.json ------------->│
│ {action: "calibrate", rpp: "..."} │
│ │
│<-- write result.json ---------------│
│ {status: "ok", lufs: -14.2} │
```
### Phase 2 Steps (inside REAPER via ReaScript)
1. Open .rpp via `Main_openProject`
2. Verify FX loaded: iterate tracks, call `TrackFX_GetFXName` for each slot
3. Set track volumes/pans/sends: `SetMediaTrackInfo_Value` + `CreateTrackSend`
4. Render: `Main_RenderFile` with format settings
5. Measure loudness: `CalcMediaSrcLoudness` on rendered file
6. Write result.json with status + metrics
### File Layout
```
src/reaper_scripting/
__init__.py # ReaScriptGenerator class
commands.py # command protocol (read/write JSON)
scripts/
run_in_reaper.py # CLI: run phase2 on a .rpp file
```
## Affected Areas
| Area | Impact | Description |
|------|--------|-------------|
| `src/reaper_scripting/__init__.py` | New | ReaScript generator + command protocol |
| `scripts/run_in_reaper.py` | New | CLI entry point for Phase 2 |
| `.sdd/changes/reascript-hybrid/` | New | Change artifact folder |
## Risks
| Risk | Likelihood | Mitigation |
|------|------------|------------|
| ReaScript API changed in REAPER version | Medium | Pin to known API subset; log API availability on startup |
| FX fail silently on load | Medium | Verify each FX chain post-load, report missing plugins |
| Rendering blocks REAPER UI | Low | Run render in background thread via ReaScript; poll for completion |
| JSON protocol desync | Low | Version the command protocol; timeout with retry |
## Rollback Plan
1. Revert `src/reaper_scripting/` and `scripts/run_in_reaper.py` deletions
2. Phase 1 (.rpp generation) is unaffected — it already works standalone
3. No schema or compose.py changes
## Dependencies
- REAPER v7+ installed with Python 3.x ReaScript support
- Plugins must be in same paths as .rpp expects (no change to path handling)
## Success Criteria
- [ ] `python scripts/run_in_reaper.py output/song.rpp` — REAPER opens, calibrates, renders, reports LUFS
- [ ] Loudness result written to `output/song_lufs.json`
- [ ] Missing FX logged to `output/song_fx_errors.json`
- [ ] Phase 1 still works standalone: `python scripts/compose.py --output output/song.rpp`

View File

@@ -0,0 +1,186 @@
# Delta for reascript-generator
## ADDED Requirements
### Requirement: ReaScriptGenerator — File Output
The system SHALL generate a self-contained Python ReaScript file that REAPER can execute via a custom Action.
The `ReaScriptGenerator` class MUST expose a `generate(path: Path, command: ReaScriptCommand) -> None` method that writes a valid Python 3.x ReaScript to `path`.
The generated script MUST include:
- A `MAIN` block that reads a JSON command file, executes the action, and writes a JSON result file
- ReaScript API calls for all required operations (open project, verify FX, calibrate, render, measure loudness)
- Proper error handling that writes error status to the result JSON
#### Scenario: Generate ReaScript for open-verify-render-loudness pipeline
- GIVEN a `ReaScriptCommand` with `action="calibrate"`, `rpp_path="output/song.rpp"`, `render_path="output/song.wav"`, and `timeout=120`
- WHEN `generator.generate(path, command)` is called
- THEN a Python file is written to `path` that calls `Main_openProject(rpp_path)`, iterates tracks calling `TrackFX_GetCount` and `TrackFX_GetFXName`, calls `SetMediaTrackInfo_Value` for volume/pan, calls `CreateTrackSend` for sends, calls `Main_RenderFile`, and calls `CalcMediaSrcLoudness` on the rendered file
- AND the script writes a result JSON with fields: `status` ("ok" | "error"), `lufs` (float or null), `fx_errors` (list), `track_count` (int)
#### Scenario: Generate ReaScript for FX verification only
- GIVEN a `ReaScriptCommand` with `action="verify_fx"` and `rpp_path="output/song.rpp"`
- WHEN `generator.generate(path, command)` is called
- THEN the generated script opens the project, iterates all tracks, calls `TrackFX_GetCount` and `TrackFX_GetFXName` for each FX slot, and writes a result JSON listing loaded FX names and any slots where `TrackFX_GetFXName` returned an empty string (missing plugin)
### Requirement: Command Protocol — JSON File Contract
The system SHALL use a versioned JSON command file for two-way communication with the ReaScript.
The command file (written by our Python, read by ReaScript) SHALL have schema:
```json
{
"version": 1,
"action": "calibrate" | "verify_fx" | "render",
"rpp_path": "absolute path to .rpp",
"render_path": "absolute path for rendered output (wav)",
"timeout": 120,
"track_calibration": [
{
"track_index": 0,
"volume": 0.85,
"pan": 0.0,
"sends": [{"dest_track_index": 5, "level": 0.05}]
}
]
}
```
The result file (written by ReaScript, read by our Python) SHALL have schema:
```json
{
"version": 1,
"status": "ok" | "error" | "timeout",
"message": "optional error message",
"lufs": -14.2,
"integrated_lufs": -14.2,
"short_term_lufs": -12.1,
"fx_errors": [
{"track_index": 2, "fx_index": 1, "name": "", "expected": "Serum_2"}
],
"tracks_verified": 8
}
```
#### Scenario: Command file round-trip
- GIVEN a valid `ReaScriptCommand` dataclass
- WHEN `write_command(path, cmd)` is called
- THEN a JSON file is written to `path` with the exact schema above
- AND `read_result(path)` returns a `ReaScriptResult` with parsed fields
#### Scenario: Version mismatch handling
- GIVEN `read_result(path)` is called and the result JSON has `version` ≠ 1
- THEN a `ProtocolVersionError` SHALL be raised with a message indicating the mismatch
### Requirement: run_in_reaper.py — CLI Entry Point
The system SHALL provide a CLI script at `scripts/run_in_reaper.py` that accepts a `.rpp` path and runs Phase 2 (calibrate + render + measure loudness).
Usage: `python scripts/run_in_reaper.py <rpp_path> [--output <wav_path>] [--timeout <seconds>]`
The CLI SHALL:
1. Generate a ReaScript file to a watched folder (e.g. `REAPER ResourcePath()/scripts/fl_control_phase2.py`)
2. Write the command JSON to `REAPER ResourcePath()/scripts/fl_control_command.json`
3. Poll `REAPER ResourcePath()/scripts/fl_control_result.json` until it exists or timeout is reached
4. Parse the result and print loudness metrics
5. Exit with code 0 on success, non-zero on failure
#### Scenario: Successful Phase 2 run
- GIVEN REAPER is running with the custom Action registered
- AND the .rpp file at `output/song.rpp` exists with valid tracks
- WHEN `python scripts/run_in_reaper.py output/song.rpp --output output/song.wav` is executed
- THEN the script generates the ReaScript, writes command JSON, waits for result JSON, and prints the LUFS measurement
- AND files `output/song_lufs.json` and `output/song_fx_errors.json` are written with results
#### Scenario: Missing FX detection
- GIVEN a track in the .rpp references a plugin not installed in REAPER
- WHEN Phase 2 runs and the ReaScript calls `TrackFX_GetFXName` on that slot
- THEN `fx_errors` in the result JSON SHALL contain an entry with the track index, FX index, and empty name
- AND the CLI writes `output/song_fx_errors.json` with the error details
#### Scenario: Timeout on REAPER non-response
- GIVEN REAPER is not running or the Action is not triggered
- WHEN the CLI polls for `fl_control_result.json` beyond the timeout
- THEN the CLI SHALL exit with code 2 and print a timeout message
### Requirement: ReaScript API Subset
The system SHALL only use a stable, documented subset of the ReaScript API to minimize version compatibility issues.
Required API functions:
- `Main_openProject(path)` — open .rpp project file
- `TrackFX_GetCount(track)` — get number of FX on track
- `TrackFX_GetFXName(track, fx, buf, bufsize)` — get FX name by index
- `TrackFX_AddByName(track, fxname, recFX, instantiate)` — add FX by name (for remediation)
- `SetMediaTrackInfo_Value(track, paramname, value)` — set VOLUME, PAN, etc.
- `CreateTrackSend(tr, desttr)` — create send to destination track
- `Main_RenderFile(proj, filename)` — render project to file (or use project render settings)
- `GetProjectName(proj, buf, bufsize)` — get current project name
- `GetTrackNumMediaItems(track)` — count items on track
- `GetMediaItem(track, idx)` — get media item by index
- `GetMediaItemTake(mediaitem, idx)` — get take from item
- `GetMediaItemTake_Source(take)` — get source from take
- `GetMediaSourceType(src, buf)` — check if source is audio/video
- `PCM_Source_GetSectionInfo(src)` — get source length
- `GetMediaSourceFileName(src, buf, bufsize)` — get source file path
- `SetMediaItemInfo_Value(item, param, value)` — set item properties
- `SetMediaItemLength(item, length, update)` — set item length
- `AddMediaItemToTrack(track)` — add new media item
- `CreateNewMediaItem(track)` — create new item
- `GetTrack(guid, flag)` — get track by GUID (flag=0)
- `GetTrackGUID(track, buf)` — get track GUID
- `GetItemOwnership(mediaitem)` — item ownership check
- `DeleteTrack(track)` — delete track
- `DeleteMediaItem(mediaitem)` — delete media item
- `Undo_OnStateChange(s)` — mark undo point
- `PreventUIRefresh(PreventCount)` — prevent UI refresh during batch ops
- `OnPauseButton()` — pause playback
- `OnPlayButton()` — start playback
- `GetPlayState()` — get play state (0=stopped, 1=playing, 2=paused)
- `GetCursorPosition()` — get playhead position in seconds
- `GetProjectLengthSeconds(proj)` — get project length
- `GetUserInputs(title, captions, retvals)` — optional: prompt user for input
- `ShowConsoleMsg(msg)` — debug output to REAPER console
- `TimeMap_cur_qn_to_beats(qn)` — convert quarter notes to beats
- `TimeMap_qn_to_time(qn)` — convert quarter notes to time in seconds
- `GetFunctionMetadata(functionName)` — detect if function exists
#### Scenario: API availability check on startup
- GIVEN the ReaScript is executed inside REAPER
- WHEN the script starts
- THEN it SHALL call `GetFunctionMetadata("Main_openProject")` to verify the required API functions are available
- AND if any required function is missing, write `{"status": "error", "message": "API not available"}` to result JSON and exit
### Requirement: Phase 2 Pipeline Steps
The ReaScript SHALL execute these steps in order when `action="calibrate"`:
1. **Open Project**: Call `Main_openProject(rpp_path)` to load the .rpp
2. **Verify FX**: Iterate all tracks, for each call `TrackFX_GetCount(track)`, then for each FX slot call `TrackFX_GetFXName` — log empty/missing plugins
3. **Calibrate Tracks**: For each entry in `track_calibration`, call `SetMediaTrackInfo_Value(track, "VOLUME", volume)` and `SetMediaTrackInfo_Value(track, "PAN", pan)`, then for each send call `CreateTrackSend`
4. **Render**: Call `Main_RenderFile` with the project and `render_path` (or use project render settings if `render_path` is not provided)
5. **Measure Loudness**: Call `CalcMediaSrcLoudness` on the rendered WAV file to obtain integrated LUFS, short-term LUFS
6. **Write Result**: Write the result JSON with status, lufs metrics, and fx_errors
#### Scenario: Full pipeline execution
- GIVEN a valid .rpp with 8 tracks, each with 1-3 FX plugins, and valid render settings
- WHEN the ReaScript executes with `action="calibrate"`
- THEN all 6 steps execute in order
- AND the result JSON contains `status: "ok"`, `lufs` (integrated LUFS), `fx_errors: []`, and `tracks_verified: 8`
#### Scenario: FX verification catches missing plugin
- GIVEN track 3 has 2 FX slots but slot 1 contains a missing plugin (empty string from `TrackFX_GetFXName`)
- WHEN the ReaScript runs FX verification
- THEN `fx_errors` SHALL contain `{"track_index": 3, "fx_index": 1, "name": "", "expected": ""}`
- AND rendering SHALL still proceed (verification does not halt the pipeline)

View File

@@ -0,0 +1,36 @@
# Tasks: reascript-hybrid
## Phase 1: Foundation — Protocol Layer
- [x] 1.1 Create `src/reaper_scripting/commands.py` with `ReaScriptCommand` and `ReaScriptResult` dataclasses matching the JSON schemas in the spec
- [x] 1.2 Implement `write_command(path: Path, cmd: ReaScriptCommand) -> None` — serializes to JSON with `version: 1`
- [x] 1.3 Implement `read_result(path: Path) -> ReaScriptResult` — deserializes JSON; raises `ProtocolVersionError` if `version != 1`
- [x] 1.4 Add `ProtocolVersionError` exception class
## Phase 2: Core — ReaScript Generator
- [x] 2.1 Create `src/reaper_scripting/__init__.py` with `ReaScriptGenerator` class
- [x] 2.2 Implement `generate(path: Path, command: ReaScriptCommand) -> None` — writes a self-contained Python ReaScript
- [x] 2.3 The generated script must include hand-rolled JSON parser (~20 lines of string splitting) — no `import json`
- [x] 2.4 The generated script must call `GetFunctionMetadata` to verify API availability on startup
- [x] 2.5 The generated script must implement the full Phase 2 pipeline: open project → verify FX → calibrate tracks → render → measure LUFS → write result
- [x] 2.6 The generated script must handle errors and write `{"status": "error", "message": "..."}` on failure
- [x] 2.7 Write `tests/test_commands.py` — test JSON round-trip, version mismatch raises `ProtocolVersionError`
- [x] 2.8 Write `tests/test_reagenerator.py` — parse generated script with `ast.parse`, verify it contains required API calls (`Main_openProject`, `TrackFX_GetCount`, `TrackFX_GetFXName`, `SetMediaTrackInfo_Value`, `CreateTrackSend`, `Main_RenderFile`, `CalcMediaSrcLoudness`)
## Phase 3: Integration — CLI Orchestration
- [x] 3.1 Create `scripts/run_in_reaper.py` CLI entry point
- [x] 3.2 CLI must accept `python scripts/run_in_reaper.py <rpp_path> [--output <wav_path>] [--timeout <seconds>]`
- [x] 3.3 CLI generates ReaScript file to `REAPER ResourcePath()/scripts/fl_control_phase2.py`
- [x] 3.4 CLI writes command JSON to `REAPER ResourcePath()/scripts/fl_control_command.json`
- [x] 3.5 CLI polls for `fl_control_result.json` until it exists or timeout reached
- [x] 3.6 CLI parses result and prints LUFS metrics; exits 0 on success, 2 on timeout, 1 on error
- [x] 3.7 CLI writes `output/song_lufs.json` and `output/song_fx_errors.json` on success
## Phase 4: Integration Test (Manual)
- [ ] 4.1 Run `pytest tests/test_phase2.py -k integration` against live REAPER with registered custom Action — skipped in CI
- [ ] 4.2 Verify full pipeline: .rpp opens, FX verified, tracks calibrated, render completes, LUFS measured
- [ ] 4.3 Verify `fx_errors` correctly identifies a missing plugin slot (empty string from `TrackFX_GetFXName`)
- [ ] 4.4 Verify timeout exits with code 2 when REAPER is not running