From 9837002c245306b6d15366172cd7fed0f7ebe6dd Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Wed, 15 Jul 2026 20:24:56 +0900 Subject: test: MML音階と2A02波形出力の適合テストを追加 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C4〜C5のMIDI音番号とtimer値を検証 - Pulse、TriangleのPCM・WAV周波数測定を追加 - Noise periodとLFSRの適合テストを追加 - 2A02 timerのclamp・mute境界を検証 - Noise計算をテスト可能な純粋関数へ分離 - 音階テスト資料を実装クロックへ同期 --- tests/test_synthesizer_pitch.py | 493 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 493 insertions(+) create mode 100644 tests/test_synthesizer_pitch.py (limited to 'tests') diff --git a/tests/test_synthesizer_pitch.py b/tests/test_synthesizer_pitch.py new file mode 100644 index 0000000..c774637 --- /dev/null +++ b/tests/test_synthesizer_pitch.py @@ -0,0 +1,493 @@ +"""End-to-end pitch conformance tests for MML and the NTSC 2A03 renderer.""" + +import math +import wave + +import numpy as np +import pytest + +from mml_composemusic_mcp.ir import NoteEvent, SweepEvent +from mml_composemusic_mcp.server import _dict_to_note_sequence, _parse_mml +from mml_composemusic_mcp.synthesizer import ( + CPU_CLOCK, + NOISE_PERIODS, + ChannelSynthesizer, + _clock_noise_lfsr, + _noise_period_index, + frequency_to_timer, + midi_to_freq, + synthesize, + timer_to_frequency, + write_wav, +) + +PITCH_CASES = ( + ("C4", 60, 427, 213), + ("C#4", 61, 403, 201), + ("D4", 62, 380, 189), + ("D#4", 63, 359, 179), + ("E4", 64, 338, 169), + ("F4", 65, 319, 159), + ("F#4", 66, 301, 150), + ("G4", 67, 284, 142), + ("G#4", 68, 268, 134), + ("A4", 69, 253, 126), + ("A#4", 70, 239, 119), + ("B4", 71, 225, 112), + ("C5", 72, 213, 106), +) + +CHROMATIC_MML = { + "ppmck": "A t120 l4 o4 c c+ d d+ e f f+ g g+ a a+ b > c", + "pyxel": "0: T120 L4 O4 C C+ D D+ E F F+ G G+ A A+ B > C", +} + +OCTAVE_MML = { + "ppmck": "A t120 l4 o4 c > c < c", + "pyxel": "0: T120 L4 O4 C > C < C", +} + +ENHARMONIC_MML = { + "ppmck": "A t120 l1 o4 @2 q8 c+ d-", + "pyxel": "0: T120 L1 O4 V127 Q100 @1 C+ D-", +} + +CHANNEL_MML = { + "ppmck": "A o4 c\nB o4 c\nT o4 c\nN o4 c", + "pyxel": "0: O4 C\n1: O4 C\n2: O4 @0 C\n3: O4 @3 C", +} + +WAV_CASES = ( + ("ppmck", "Pulse1", "A t120 l1 o4 @2 q8 c", 60, "pulse"), + ("ppmck", "Pulse2", "B t120 l1 o4 @2 q8 a", 69, "pulse"), + ("ppmck", "Triangle", "T t120 l1 o5 c", 72, "triangle"), + ("pyxel", "Pulse1", "0: T120 L1 O4 V127 Q100 @1 C", 60, "pulse"), + ("pyxel", "Pulse2", "1: T120 L1 O4 V127 Q100 @1 A", 69, "pulse"), + ("pyxel", "Triangle", "2: T120 L1 O5 V127 Q100 @0 C", 72, "triangle"), +) + +NOISE_WAV_CASES = ( + ("ppmck", "N t120 l1 v15 c"), + ("pyxel", "3: T120 L1 O4 V127 Q100 @3 C"), +) + +LONG_LFSR_GOLDEN = ( + 16384, + 8192, + 4096, + 2048, + 1024, + 512, + 256, + 128, + 64, + 32, + 16, + 8, + 4, + 2, + 16385, + 24576, +) + +SHORT_LFSR_GOLDEN = ( + 16384, + 8192, + 4096, + 2048, + 1024, + 512, + 256, + 128, + 64, + 16416, + 8208, + 4104, + 2052, + 1026, + 513, + 16640, +) + + +def compile_mml(source: str, mode: str): # type: ignore[no-untyped-def] + data, errors = _parse_mml(source, mode) + assert not [error for error in errors if error.severity == "error"] + assert data is not None + return _dict_to_note_sequence(data), errors + + +def channel_notes(note_sequence, channel: str) -> list[NoteEvent]: # type: ignore[no-untyped-def] + return [ + event + for event in note_sequence.channels[channel].events + if isinstance(event, NoteEvent) + ] + + +def timer_frequency(timer: int, channel_type: str) -> float: + divider = 16 if channel_type == "pulse" else 32 + return CPU_CLOCK / (divider * (timer + 1)) + + +def cents_error(measured: float, expected: float) -> float: + return 1200.0 * math.log2(measured / expected) + + +def estimate_periodic_frequency( + samples: np.ndarray, sample_rate: int, trim_seconds: float = 0.05 +) -> float: + """Measure one upward midpoint crossing per pulse/triangle cycle.""" + trim = int(trim_seconds * sample_rate) + values = np.asarray(samples, dtype=np.float64) + if trim and len(values) > 2 * trim: + values = values[trim:-trim] + threshold = (float(values.max()) + float(values.min())) / 2.0 + high = values >= threshold + rising = np.flatnonzero((~high[:-1]) & high[1:]) + 1 + if len(rising) < 3: + raise AssertionError("周波数測定に必要な立ち上がりエッジがありません。") + elapsed_samples = int(rising[-1] - rising[0]) + cycle_count = len(rising) - 1 + return cycle_count * sample_rate / elapsed_samples + + +def read_pcm16(path) -> tuple[int, np.ndarray]: # type: ignore[no-untyped-def] + with wave.open(str(path), "rb") as wav: + assert wav.getnchannels() == 1 + assert wav.getsampwidth() == 2 + sample_rate = wav.getframerate() + samples = np.frombuffer(wav.readframes(wav.getnframes()), dtype="> 1) > 0x7FF + assert np.all(swept_wave == 0) -- cgit v1.2.3