"""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)