- section-energy: track activity matrix + volume/velocity multipliers per section - smart-chords: ChordEngine with voice leading, inversions, 4 emotion modes - hook-melody: melody engine with hook/stabs/smooth styles, call-and-response - mix-calibration: Calibrator module (LUFS volumes, HPF/LPF, stereo, sends, master) - transitions-fx: FX track with risers/impacts/sweeps at section boundaries - sidechain: MIDI CC11 bass ducking on kick hits via DrumLoopAnalyzer - presets-pack: role-aware plugin presets (Serum/Decapitator/Omnisphere per role) Full SDD pipeline (propose→spec→design→tasks→apply→verify) for all 7 changes. 302/302 tests passing.
623 lines
24 KiB
Python
623 lines
24 KiB
Python
"""Tests for src/reaper_builder/ — RPPBuilder, rpp_writer."""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parents[1]))
|
|
|
|
import pytest
|
|
import tempfile
|
|
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, PluginDef, CCEvent
|
|
from src.reaper_builder import RPPBuilder
|
|
|
|
|
|
class TestRPPBuilderWrite:
|
|
"""Test RPPBuilder.write() produces valid .rpp output."""
|
|
|
|
def test_write_produces_reaper_project_marker(self):
|
|
"""RPPBuilder.write() produces a file containing 'REAPER_PROJECT'."""
|
|
meta = SongMeta(bpm=95, key="Am", title="Test")
|
|
song = SongDefinition(meta=meta, tracks=[])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
assert "REAPER_PROJECT" in content
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
def test_write_produces_tempo_line(self):
|
|
"""Output contains TEMPO line with correct BPM."""
|
|
meta = SongMeta(bpm=95, key="Am")
|
|
song = SongDefinition(meta=meta, tracks=[])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
assert "TEMPO 95 " in content
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
|
|
class TestRPPBuilderAudioTrack:
|
|
"""Test audio track generates SOURCE WAVE block with correct file path."""
|
|
|
|
def test_audio_track_generates_source_wave_block(self):
|
|
"""Audio clip produces <SOURCE WAVE> block with FILE path."""
|
|
meta = SongMeta(bpm=95, key="Am")
|
|
clip = ClipDef(
|
|
position=0.0,
|
|
length=16.0,
|
|
name="Kick Loop",
|
|
audio_path="C:/samples/kick.wav",
|
|
)
|
|
track = TrackDef(name="Drums", clips=[clip])
|
|
song = SongDefinition(meta=meta, tracks=[track])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
assert "<SOURCE WAVE\n" in content
|
|
assert 'FILE C:/samples/kick.wav' in content
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
def test_audio_track_includes_track_name(self):
|
|
"""Audio track block contains the track NAME."""
|
|
meta = SongMeta(bpm=95, key="Am")
|
|
clip = ClipDef(position=0.0, length=16.0, audio_path="C:/kick.wav")
|
|
track = TrackDef(name="Kick", clips=[clip])
|
|
song = SongDefinition(meta=meta, tracks=[track])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
assert "NAME Kick" in content
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
|
|
class TestRPPBuilderMidiTrack:
|
|
"""Test MIDI track generates MIDI event lines (E lines)."""
|
|
|
|
def test_midi_track_generates_e_lines(self):
|
|
"""MIDI clip produces E event lines in the output."""
|
|
meta = SongMeta(bpm=95, key="Am")
|
|
note = MidiNote(pitch=36, start=0.0, duration=0.25, velocity=115)
|
|
clip = ClipDef(position=0.0, length=16.0, name="Kick Pattern", midi_notes=[note])
|
|
track = TrackDef(name="Drums", clips=[clip])
|
|
song = SongDefinition(meta=meta, tracks=[track])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
assert "<SOURCE MIDI\n" in content
|
|
assert "E " in content
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
def test_midi_track_note_on_off_pairs(self):
|
|
"""MIDI note produces note-on (90) and note-off (80) E lines."""
|
|
meta = SongMeta(bpm=95, key="Am")
|
|
notes = [
|
|
MidiNote(pitch=36, start=0.0, duration=0.25, velocity=115),
|
|
MidiNote(pitch=36, start=1.5, duration=0.25, velocity=105),
|
|
]
|
|
clip = ClipDef(position=0.0, length=16.0, name="Kick Pattern", midi_notes=notes)
|
|
track = TrackDef(name="Drums", clips=[clip])
|
|
song = SongDefinition(meta=meta, tracks=[track])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
# Note on: status 90, pitch, velocity
|
|
assert "90" in content
|
|
# Note off: status 80, pitch, 00
|
|
assert "80" in content
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
|
|
class TestRPPBuilderMasterTrack:
|
|
"""Test that RPPBuilder includes a master track."""
|
|
|
|
def test_output_contains_master_track(self):
|
|
"""Generated .rpp contains a master track."""
|
|
meta = SongMeta(bpm=95, key="Am")
|
|
song = SongDefinition(meta=meta, tracks=[])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
assert "NAME master" in content
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
|
|
class TestRPPProjectFormat:
|
|
"""Test output matches the ground truth format from output/test_vst3.rpp."""
|
|
|
|
def test_header_version_765_win64(self):
|
|
"""REAPER_PROJECT line has version 7.65/win64 (not unquoted 6.0)."""
|
|
meta = SongMeta(bpm=95, key="Am", title="Test")
|
|
song = SongDefinition(meta=meta, tracks=[])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
first_line = content.split('\n', 1)[0]
|
|
# Version must be 7.65/win64, not 6.0
|
|
assert "7.65/win64" in first_line
|
|
# Must NOT contain the old 6.0 version
|
|
assert "6.0" not in first_line
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
def test_peakgain_and_panlaw_present(self):
|
|
"""Output contains PEAKGAIN and PANLAW lines from ground truth."""
|
|
meta = SongMeta(bpm=95, key="Am")
|
|
song = SongDefinition(meta=meta, tracks=[])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
assert "PEAKGAIN 1" in content
|
|
assert "PANLAW 1" in content
|
|
assert "SAMPLERATE 44100" in content
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
def test_track_has_all_default_attributes(self):
|
|
"""TRACK element contains all 25 default attributes from ground truth."""
|
|
meta = SongMeta(bpm=95, key="Am")
|
|
track = TrackDef(name="Test Track", clips=[])
|
|
song = SongDefinition(meta=meta, tracks=[track])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
# Key attributes that uniquely identify the ground truth format
|
|
assert "PEAKCOL 16576" in content
|
|
assert "BEAT -1" in content
|
|
assert "AUTOMODE 0" in content
|
|
assert "NCHAN 2" in content
|
|
assert "FX 1" in content
|
|
assert "TRACKID {" in content
|
|
assert "VU 64" in content
|
|
assert "INQ 0 0 0 0.5" in content
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
def test_fxchain_has_required_structure(self):
|
|
"""FXCHAIN block has WNDRECT, SHOW, BYPASS, FXID lines."""
|
|
meta = SongMeta(bpm=95, key="Am")
|
|
plugin = PluginDef(name="Serum2", path="Serum2.vst3", index=0)
|
|
track = TrackDef(name="Bass", clips=[], plugins=[plugin])
|
|
song = SongDefinition(meta=meta, tracks=[track])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
assert "WNDRECT 24 52 655 408" in content
|
|
assert "SHOW 0" in content
|
|
assert "DOCKED 0" in content
|
|
assert "BYPASS 0 0 0" in content
|
|
assert "FXID {" in content
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
def test_metronome_block_structure(self):
|
|
"""METRONOME is a parent element with proper children, not flat attributes."""
|
|
meta = SongMeta(bpm=95, key="Am")
|
|
song = SongDefinition(meta=meta, tracks=[])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
assert "<METRONOME" in content
|
|
assert "PATTERNSTR ABBB" in content
|
|
assert "SAMPLES \"\" \"\" \"\" \"\"" in content
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
def test_master_track_has_fxchain(self):
|
|
"""Master track has FXCHAIN block (MASTER_FX 1 requires it)."""
|
|
meta = SongMeta(bpm=95, key="Am")
|
|
song = SongDefinition(meta=meta, tracks=[])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
# Count FXCHAIN blocks - master + any user tracks
|
|
fxchain_count = content.count("<FXCHAIN")
|
|
assert fxchain_count >= 1, f"Expected at least 1 FXCHAIN, got {fxchain_count}"
|
|
# Master track FXCHAIN has master-specific FXID
|
|
assert "FXID {" in content
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
|
|
class TestVST3GUIDPresence:
|
|
"""Test that VST3 plugins output with uniqueid{GUID} tokens."""
|
|
|
|
def test_vst3_plugin_output_contains_guid(self):
|
|
"""VST3 element contains GUID from registry lookup."""
|
|
meta = SongMeta(bpm=95, key="Am", title="VST3 Test")
|
|
plugin = PluginDef(name="Serum2", path="Serum2.vst3", index=0)
|
|
track = TrackDef(name="Bass", clips=[], plugins=[plugin])
|
|
song = SongDefinition(meta=meta, tracks=[track])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
# Must contain the GUID token from VST3_REGISTRY["Serum2"]
|
|
assert "691258006{56534558667350736572756D20320000}" in content
|
|
# Must also contain correct display name and filename
|
|
assert "VST3i: Serum 2 (Xfer Records)" in content
|
|
assert "Serum2.vst3" in content
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
def test_fabfilter_proq3_contains_guid(self):
|
|
"""FabFilter Pro-Q 3 outputs with correct GUID."""
|
|
meta = SongMeta(bpm=95, key="Am", title="VST3 Test")
|
|
plugin = PluginDef(name="FabFilter Pro-Q 3", path="FabFilter Pro-Q 3.vst3", index=0)
|
|
track = TrackDef(name="Lead", clips=[], plugins=[plugin])
|
|
song = SongDefinition(meta=meta, tracks=[track])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
# Must contain the GUID token from VST3_REGISTRY["FabFilter Pro-Q 3"]
|
|
assert "756089518{72C4DB717A4D459AB97E51745D84B39D}" in content
|
|
assert "VST3: Pro-Q 3 (FabFilter)" in content
|
|
# Filename in RPP is "FabFilter" (shared binary for all FabFilter plugins)
|
|
assert "FabFilter 0" in content
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
|
|
class TestVST3PresetData:
|
|
"""Test that VST3 plugins include base64 preset data inside VST blocks."""
|
|
|
|
def test_serum2_vst_contains_preset_data(self):
|
|
"""Serum2 VST block contains base64 preset lines."""
|
|
meta = SongMeta(bpm=95, key="Am", title="VST3 Preset Test")
|
|
plugin = PluginDef(name="Serum2", path="Serum2.vst3", index=0)
|
|
track = TrackDef(name="Bass", clips=[], plugins=[plugin])
|
|
song = SongDefinition(meta=meta, tracks=[track])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
# Serum2 preset starts with this magic line (first base64 line)
|
|
assert "Z4R+ae5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAbQgAAAEAAAAAAAAA" in content
|
|
# Last line of all presets is the same terminator
|
|
assert "AFByb2dyYW0gMQAAAAAA" in content
|
|
# A mid-preset line (line 2)
|
|
assert "zQQAAAEAAABYZmVySnNvbgC5AAAAAAAAAHsiY29tcG9uZW50IjoicHJvY2Vzc29yIiwiaGFzaCI6IjgxZTEyMWYxNGI2Y2IyYjA2YzMzMjQzZDk1ZDIxYWIxIiwicHJv" in content
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
def test_fabfilter_proq3_vst_contains_preset_data(self):
|
|
"""FabFilter Pro-Q 3 VST block contains base64 preset lines."""
|
|
meta = SongMeta(bpm=95, key="Am", title="VST3 Preset Test")
|
|
plugin = PluginDef(name="FabFilter Pro-Q 3", path="FabFilter Pro-Q 3.vst3", index=0)
|
|
track = TrackDef(name="Lead", clips=[], plugins=[plugin])
|
|
song = SongDefinition(meta=meta, tracks=[track])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
# Pro-Q 3 preset starts with this line
|
|
assert "rgIRLe5e7f4EAAAAAQAAAAAAAAACAAAAAAAAAAQAAAAAAAAACAAAAAAAAAACAAAAAQAAAAAAAAACAAAAAAAAAAoGAAABAAAAAAAAAA==" in content
|
|
assert "AFByb2dyYW0gMQAAAAAA" in content
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
def test_registry_plugins_with_preset_data_are_in_output(self):
|
|
"""VST3 plugins with non-empty preset data include that data in output.
|
|
|
|
New plugins added with empty preset lists ([]) are handled gracefully —
|
|
vst3_element() only appends preset lines when preset_data is truthy.
|
|
"""
|
|
meta = SongMeta(bpm=95, key="Am", title="VST3 Preset Test")
|
|
# Use actual filenames from registry so _build_plugin recognizes them as VST3
|
|
from src.reaper_builder import VST3_REGISTRY
|
|
plugins = [
|
|
PluginDef(name=name, path=entry[1], index=i)
|
|
for i, (name, entry) in enumerate(VST3_REGISTRY.items())
|
|
]
|
|
track = TrackDef(name="Test", clips=[], plugins=plugins)
|
|
song = SongDefinition(meta=meta, tracks=[track])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
# Check that plugins WITH preset data have that data in output
|
|
from src.reaper_builder import PLUGIN_PRESETS, VST3_REGISTRY
|
|
vst3_keys = set(VST3_REGISTRY.keys())
|
|
for (name, role), preset_lines in PLUGIN_PRESETS.items():
|
|
# Only check VST3 plugins and default role (backward compat)
|
|
if name not in vst3_keys or role != "":
|
|
continue
|
|
if len(preset_lines) > 0:
|
|
# Check first preset line — most distinctive, no collision risk
|
|
first_line = preset_lines[0]
|
|
assert first_line in content, f"{name} preset line not found in output"
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
|
|
class TestDVolEmission:
|
|
"""Test D_VOL emission for audio clips with vol_mult."""
|
|
|
|
def test_audio_clip_vol_mult_not_one_emits_dvol(self):
|
|
"""Audio clip with vol_mult=0.7 emits D_VOL in ITEM."""
|
|
meta = SongMeta(bpm=95, key="Am")
|
|
clip = ClipDef(
|
|
position=0.0, length=16.0, name="Test",
|
|
audio_path="C:/test.wav", vol_mult=0.7,
|
|
)
|
|
track = TrackDef(name="Test Track", clips=[clip])
|
|
song = SongDefinition(meta=meta, tracks=[track])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
assert "D_VOL 0.7" in content, "D_VOL line expected for vol_mult=0.7"
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
def test_audio_clip_default_vol_mult_emits_no_dvol(self):
|
|
"""Audio clip with default vol_mult=1.0 emits NO D_VOL."""
|
|
meta = SongMeta(bpm=95, key="Am")
|
|
clip = ClipDef(
|
|
position=0.0, length=16.0, name="Test",
|
|
audio_path="C:/test.wav", # default vol_mult=1.0
|
|
)
|
|
track = TrackDef(name="Test Track", clips=[clip])
|
|
song = SongDefinition(meta=meta, tracks=[track])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
assert "D_VOL" not in content, "No D_VOL expected for default vol_mult"
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
def test_midi_clip_vol_mult_scales_velocity(self):
|
|
"""MIDI clip vol_mult scales velocity in output E lines."""
|
|
meta = SongMeta(bpm=95, key="Am")
|
|
note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=100)
|
|
clip = ClipDef(
|
|
position=0.0, length=16.0, name="Test MIDI",
|
|
midi_notes=[note], vol_mult=0.5,
|
|
)
|
|
track = TrackDef(name="MIDI Track", clips=[clip])
|
|
song = SongDefinition(meta=meta, tracks=[track])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
# Velocity should be 100 * 0.5 = 50 = 0x32
|
|
# The E line format: E <delta> 90 <pitch_hex> <vel_hex>
|
|
# pitch 60 = 0x3c, velocity 50 = 0x32
|
|
assert "90 3c 32" in content, f"Expected velocity 50 (0x32) in E line, got content fragment"
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
|
|
class TestCCEmission:
|
|
"""Test that CC11 events are emitted as B0 0B E-lines."""
|
|
|
|
def test_midi_source_with_cc_events(self):
|
|
"""_build_midi_source emits B0 0B lines for clips with midi_cc."""
|
|
meta = SongMeta(bpm=95, key="Am")
|
|
note = MidiNote(pitch=60, start=0.5, duration=1.0, velocity=100)
|
|
cc = CCEvent(controller=11, time=0.0, value=50)
|
|
clip = ClipDef(
|
|
position=0.0, length=16.0, name="Test",
|
|
midi_notes=[note], midi_cc=[cc],
|
|
)
|
|
track = TrackDef(name="MIDI CC", clips=[clip])
|
|
song = SongDefinition(meta=meta, tracks=[track])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
# CC event at time 0, controller 11, value 50 = 0x32
|
|
assert "B0 0b 32" in content, f"Expected CC11 B0 0b line, got: {content}"
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
def test_midi_source_cc_note_interleaved_order(self):
|
|
"""CC events interleaved with notes in time order."""
|
|
meta = SongMeta(bpm=95, key="Am")
|
|
note = MidiNote(pitch=60, start=0.5, duration=1.0, velocity=100)
|
|
cc1 = CCEvent(controller=11, time=0.0, value=50)
|
|
cc2 = CCEvent(controller=11, time=0.25, value=127)
|
|
clip = ClipDef(
|
|
position=0.0, length=16.0, name="Test",
|
|
midi_notes=[note], midi_cc=[cc1, cc2],
|
|
)
|
|
track = TrackDef(name="MIDI CC", clips=[clip])
|
|
song = SongDefinition(meta=meta, tracks=[track])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
# Find E-lines — CC should come before note in time order
|
|
e_lines = [l.strip() for l in content.split('\n') if l.strip().startswith('E ')]
|
|
# First E-line should be CC at time 0 (B0 0b)
|
|
assert any('B0' in l for l in e_lines), f"No CC E-lines found in {e_lines}"
|
|
# Find position of first CC line vs first note line
|
|
cc_indices = [i for i, l in enumerate(e_lines) if 'B0' in l]
|
|
note_indices = [i for i, l in enumerate(e_lines) if '90' in l]
|
|
assert cc_indices[0] < note_indices[0], \
|
|
f"CC at time 0 should come before note at time 0.5. E-lines: {e_lines}"
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
|
|
def test_midi_source_no_cc_when_empty(self):
|
|
"""_build_midi_source emits no B0 lines when midi_cc is empty."""
|
|
meta = SongMeta(bpm=95, key="Am")
|
|
note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=100)
|
|
clip = ClipDef(
|
|
position=0.0, length=16.0, name="Test",
|
|
midi_notes=[note], midi_cc=[],
|
|
)
|
|
track = TrackDef(name="MIDI No CC", clips=[clip])
|
|
song = SongDefinition(meta=meta, tracks=[track])
|
|
builder = RPPBuilder(song)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
|
) as f:
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
builder.write(tmp_path)
|
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
|
assert "B0" not in content, f"No CC expected, got: {content}"
|
|
finally:
|
|
Path(tmp_path).unlink(missing_ok=True)
|