summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-07-12 18:28:50 +0900
committertanaka takashi <isazaka@gmail.com>2026-07-12 18:28:50 +0900
commit3d06bee12a26bc55cd73b1494e4d008a8098f6f5 (patch)
treedd13a8f3fcd9d6dbf74d6406bfaa8894895af0ce /tests
parentbf36a773918959eeb6ffe7dae2fb8b0874438eb4 (diff)
feat: MML拡張合成とNTSC 2A03準拠のIR v2音源を実装
- エンベロープ、LFO、ビブラート、グライドのグローバル定義表を追加 - ppmckおよびPyxelの拡張コマンドを音声合成へ反映 - 2A03のPulseデューティ列、Triangle DAC、Noise LFSR、Sweepを実装 - APUタイマーに基づく周波数量子化と非線形ミキサを追加 - 途中テンポ変更、拡張コマンドの検証、トラック外定義に対応 - APU規格、拡張合成、IR v2のテストを追加 - プロジェクト独自のppmck拡張仕様と対応範囲を文書化
Diffstat (limited to 'tests')
-rw-r--r--tests/test_mml_conformance.py49
-rw-r--r--tests/test_parser_detailed.py39
-rw-r--r--tests/test_synthesizer.py98
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 ---