diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_mml_conformance.py | 49 | ||||
| -rw-r--r-- | tests/test_parser_detailed.py | 39 | ||||
| -rw-r--r-- | tests/test_synthesizer.py | 98 |
3 files changed, 147 insertions, 39 deletions
diff --git a/tests/test_mml_conformance.py b/tests/test_mml_conformance.py index f611f6e..442be81 100644 --- a/tests/test_mml_conformance.py +++ b/tests/test_mml_conformance.py @@ -135,7 +135,7 @@ def test_empty_and_unicode_inputs_do_not_crash_pipeline(mode): assert ErrorCode.SYNTAX_INVALID_TOKEN in error_codes(errors) -def test_validate_full_pyxel_path_preserves_warning_and_summary(): +def test_validate_full_pyxel_path_supports_extension_and_summary(): result = compose_mml( action="validate", mode="pyxel", @@ -143,32 +143,43 @@ def test_validate_full_pyxel_path_preserves_warning_and_summary(): ) assert result["valid"] is True assert result["channel_summary"] - assert any( - warning["code"] == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE.value - for warning in result["warnings"] - ) + assert not result["warnings"] @pytest.mark.parametrize( - ("source", "expected_warning_code"), + "source", [ - ("A D10 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), - ("A s1,2 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), - ("A v+5 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), - ("A @v0 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), - ("A @@0 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), - ("A MP1 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), - ("A MPOF c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), - ("A EP1 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), - ("A EPOF c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), - ("A EN1 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), - ("A ENOF c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), + "A D10 c", + "A s1,2 c", + "A v+5 c", + "A @v0={15|10} @v0 c", + "A @0={0|1} @@0 c", + "A @MP1={0,10,5,0} MP1 c", + "A MPOF c", + "A @EP1={0|5} EP1 c", + "A EPOF c", + "A @EN1={0|1} EN1 c", + "A ENOF c", ], ) -def test_ppmck_new_commands_produce_warning(source, expected_warning_code): +def test_ppmck_extension_commands_are_supported(source): _, _, _, errors = parse_pipeline(source, "ppmck") assert not any(e.severity == "error" for e in errors) - assert expected_warning_code in error_codes(errors) + assert ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE not in error_codes(errors) + + +def test_ppmck_definition_is_allowed_before_track(): + _, _, sequence, errors = parse_pipeline("@v1={15|8}\nA @v1 c", "ppmck") + assert not errors + assert sequence.definitions["volume_envelopes"][1] == { + "points": [15], + "loop_points": [8], + } + + +def test_ppmck_duplicate_definition_is_rejected(): + _, _, _, errors = parse_pipeline("@v1={15}\n@v1={8}\nA c", "ppmck") + assert ErrorCode.SEMANTIC_DUPLICATE_DEFINITION in error_codes(errors) def test_ppmck_at_duty_in_pulse_channel(): diff --git a/tests/test_parser_detailed.py b/tests/test_parser_detailed.py index f1f4b6a..5026058 100644 --- a/tests/test_parser_detailed.py +++ b/tests/test_parser_detailed.py @@ -77,46 +77,53 @@ def test_ppmck_tie_cmd(): assert notes[0].duration == 384 -def test_ppmck_detune_warning(): +def test_ppmck_detune_supported(): source = "A t120 l4 o4 D10\n c" ns, errors = _analyze_ppmck(source) - assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors) + assert not errors + assert ( + next( + e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent) + ).detune_cents + == 10 + ) -def test_ppmck_sweep_warning(): +def test_ppmck_sweep_supported(): source = "A t120 l4 o4 s1,2\n c" ns, errors = _analyze_ppmck(source) - assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors) + assert not errors -def test_ppmck_rel_vol_warning(): +def test_ppmck_rel_vol_supported(): source = "A t120 l4 o4 v+5\n c" ns, errors = _analyze_ppmck(source) - assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors) + assert not errors -def test_ppmck_vol_env_use_warning(): - source = "A t120 l4 o4 @v0\n c" +def test_ppmck_vol_env_use_supported(): + source = "A t120 l4 o4 @v0={15|10} @v0\n c" ns, errors = _analyze_ppmck(source) - assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors) + assert not errors -def test_ppmck_vol_env_def_warning(): +def test_ppmck_vol_env_def_supported(): source = "A t120 l4 o4\n@v0 = { 15, 10, | 5 }\n c" ns, errors = _analyze_ppmck(source) - assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors) + assert not errors + assert ns.definitions["volume_envelopes"][0]["points"] == [15, 10] -def test_ppmck_lfo_def_warning(): +def test_ppmck_lfo_def_supported(): source = "A t120 l4 o4\n@MP0 = { 0, 10, 5, 0 }\n c" ns, errors = _analyze_ppmck(source) - assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors) + assert not errors -def test_ppmck_duty_env_use_warning(): - source = "A t120 l4 o4 @@0\n c" +def test_ppmck_duty_env_use_supported(): + source = "A t120 l4 o4 @0={0|1} @@0\n c" ns, errors = _analyze_ppmck(source) - assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors) + assert not errors def test_ppmck_duty_triangle_error(): diff --git a/tests/test_synthesizer.py b/tests/test_synthesizer.py index c3e110e..3d7b61e 100644 --- a/tests/test_synthesizer.py +++ b/tests/test_synthesizer.py @@ -8,15 +8,25 @@ import pytest from mml_composemusic_mcp.ir import ( NoteEvent, NoteSequence, + TempoEvent, + VolumeEnvelopeEvent, VolumeEvent, ) from mml_composemusic_mcp.synthesizer import ( + NOISE_PERIODS, + PULSE_SEQUENCES, + TRIANGLE_SEQUENCE, ChannelSynthesizer, + TempoMap, + apu_mix, build_channel_summary, + build_tempo_map, + frequency_to_timer, midi_to_freq, noise_period_to_rate, synthesize, tick_to_second, + timer_to_frequency, write_wav, ) @@ -40,6 +50,61 @@ def test_noise_period_to_rate(): assert rate > 0 +def test_ntsc_noise_period_table(): + assert NOISE_PERIODS == ( + 4, + 8, + 16, + 32, + 64, + 96, + 128, + 160, + 202, + 254, + 380, + 508, + 762, + 1016, + 2034, + 4068, + ) + + +def test_apu_step_sequences(): + assert [sum(sequence) for sequence in PULSE_SEQUENCES] == [1, 2, 4, 6] + assert TRIANGLE_SEQUENCE[:16] == tuple(range(15, -1, -1)) + assert TRIANGLE_SEQUENCE[16:] == tuple(range(16)) + + +def test_timer_quantization_matches_2a03_formula(): + timer = frequency_to_timer(440.0, "pulse") + assert timer == round(1_789_772.5 / (16 * 440.0) - 1) + assert timer_to_frequency(timer, "pulse") == pytest.approx(440.0, rel=0.003) + + +def test_nonlinear_apu_mixer(): + one = np.array([15.0]) + zero = np.array([0.0]) + single = apu_mix(one, zero, zero, zero)[0] + double = apu_mix(one, one, zero, zero)[0] + assert 0 < single < double < single * 2 + + +def test_tempo_map_integrates_mid_song_change(): + tempo = TempoMap([(0, 120), (192, 60)]) + assert tempo.tick_to_second(192) == pytest.approx(0.5) + assert tempo.tick_to_second(384) == pytest.approx(1.5) + + +def test_note_sequence_tempo_map_keeps_default_before_late_change(): + ns = NoteSequence(bpm=120) + ns.channels["Pulse1"].events.append(TempoEvent(tick_position=192, bpm=60)) + tempo = build_tempo_map(ns) + assert tempo.tick_to_second(192) == pytest.approx(0.5) + assert tempo.tick_to_second(384) == pytest.approx(1.5) + + def test_tick_to_second(): assert tick_to_second(192, 120) == pytest.approx(0.5, rel=1e-6) @@ -65,9 +130,9 @@ def test_triangle_render_ppmck(): wave_out = synth.render(events, 192, 120, "ppmck") assert len(wave_out) > 0 assert np.any(wave_out != 0) - # In ppmck mode, triangle amplitude should be full (velocity ignored) + # Raw channel output is the 2A03's four-bit DAC value. peak = np.max(np.abs(wave_out)) - assert peak == pytest.approx(1.0, rel=1e-2) + assert peak == 15 def test_triangle_render_pyxel(): @@ -79,9 +144,9 @@ def test_triangle_render_pyxel(): wave_out = synth.render(events, 192, 120, "pyxel") assert len(wave_out) > 0 assert np.any(wave_out != 0) - # In pyxel mode, velocity/15 scales amplitude + # Triangle volume is not controlled by the 2A03 DAC. peak = np.max(np.abs(wave_out)) - assert peak == pytest.approx(7 / 15, rel=1e-1) + assert peak == 15 def test_noise_render_ppmck(): @@ -100,6 +165,17 @@ def test_noise_render_pyxel(): assert np.any(wave_out != 0) +def test_noise_short_mode_uses_different_lfsr_tap(): + events = [NoteEvent(tick_position=0, duration=192, note_number=48, velocity=15)] + long_wave = ChannelSynthesizer("noise", 8000, noise_mode=0).render( + events, 192, 120, "pyxel" + ) + short_wave = ChannelSynthesizer("noise", 8000, noise_mode=1).render( + events, 192, 120, "pyxel" + ) + assert not np.array_equal(long_wave, short_wave) + + # --- Full synthesize --- @@ -134,6 +210,20 @@ def test_synthesize_normalize(): assert np.max(np.abs(on_data)) == pytest.approx(1.0, rel=1e-2) +def test_volume_envelope_changes_apu_dac_at_48_ticks(): + definitions = {"volume_envelopes": {1: {"points": [15, 0], "loop_points": []}}} + events = [ + VolumeEnvelopeEvent(tick_position=0, slot=1), + NoteEvent(tick_position=0, duration=96, note_number=69, velocity=15), + ] + wave_out = ChannelSynthesizer("pulse", 8000).render( + events, 96, 120, "ppmck", definitions + ) + midpoint = len(wave_out) // 2 + assert np.max(wave_out[:midpoint]) == 15 + assert np.max(wave_out[midpoint:]) == 0 + + # --- WAV output --- |
