summaryrefslogtreecommitdiff
path: root/tests/test_parser_detailed.py
blob: 7033d54dd0712a64bc792d8d660f99a136b835c0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
"""Detailed parser/semantic tests for ppmck and pyxel modes."""

from mml_composemusic_mcp.ir import (
    DutyEvent,
    EnvelopeEvent,
    NoteEvent,
)
from mml_composemusic_mcp.lexer import tokenize
from mml_composemusic_mcp.parser_ppmck import parse_ppmck
from mml_composemusic_mcp.parser_pyxel import parse_pyxel
from mml_composemusic_mcp.semantic_ppmck import analyze_ppmck
from mml_composemusic_mcp.semantic_pyxel import analyze_pyxel


def _analyze_ppmck(source: str):
    tokens = tokenize(source, "ppmck")
    program, errors = parse_ppmck(source, tokens)
    if any(e.severity == "error" for e in errors):
        return None, errors
    return analyze_ppmck(source, program)


def _analyze_pyxel(source: str):
    tokens = tokenize(source, "pyxel")
    program, errors = parse_pyxel(source, tokens)
    if any(e.severity == "error" for e in errors):
        return None, errors
    return analyze_pyxel(source, program)


# --- Channel assignment ---


def test_ppmck_channel_assignment():
    source = "A t120 l4 o4\n  c\nB o4\n  d\nT o3\n  e\nN v10\n  r"
    ns, errors = _analyze_ppmck(source)
    assert not any(e.severity == "error" for e in errors)
    assert ns.channels["Pulse1"].events
    assert ns.channels["Pulse2"].events
    assert ns.channels["Triangle"].events
    assert ns.channels["Noise"].events


def test_pyxel_channel_assignment():
    source = "0: T120 L4 O4\n  C\n1: O4\n  D\n2: O3\n  E\n3: V80\n  R"
    ns, errors = _analyze_pyxel(source)
    assert not any(e.severity == "error" for e in errors)
    assert ns.channels["Pulse1"].events
    assert ns.channels["Pulse2"].events
    assert ns.channels["Triangle"].events
    assert ns.channels["Noise"].events


# --- Duty ---


def test_ppmck_duty_q():
    source = "A t120 l4 o4 q3\n  c"
    ns, errors = _analyze_ppmck(source)
    duties = [e for e in ns.channels["Pulse1"].events if isinstance(e, DutyEvent)]
    assert duties and duties[0].value == 3


def test_pyxel_at_duty():
    source = "0: T120 L4 O4 @1\n  C"
    ns, errors = _analyze_pyxel(source)
    duties = [e for e in ns.channels["Pulse1"].events if isinstance(e, DutyEvent)]
    assert duties and duties[0].value == 2  # @1 maps to duty 2 (50%)


def test_pyxel_at2_duty():
    source = "0: T120 L4 O4 @2\n  C"
    ns, errors = _analyze_pyxel(source)
    duties = [e for e in ns.channels["Pulse1"].events if isinstance(e, DutyEvent)]
    assert duties and duties[0].value == 1  # @2 maps to duty 1 (25%)


# --- Volume normalization ---


def test_pyxel_volume_normalization():
    source = "0: T120 L4 O4 V100\n  C"
    ns, errors = _analyze_pyxel(source)
    notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
    assert notes
    # V100 -> round(100/127*15) = 12
    assert notes[0].velocity == 12


def test_pyxel_volume_zero():
    source = "0: T120 L4 O4 V0\n  C"
    ns, errors = _analyze_pyxel(source)
    notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
    assert notes and notes[0].velocity == 0


def test_pyxel_volume_max():
    source = "0: T120 L4 O4 V127\n  C"
    ns, errors = _analyze_pyxel(source)
    notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
    assert notes and notes[0].velocity == 15


# --- Gate time ---


def test_pyxel_gate_time():
    source = "0: T120 L4 O4 Q50\n  C"
    ns, errors = _analyze_pyxel(source)
    notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
    assert notes and notes[0].gate_time == 0.5


# --- Transpose ---


def test_pyxel_transpose():
    source = "0: T120 L4 O4 K2\n  C"
    ns, errors = _analyze_pyxel(source)
    notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
    # C4=60, transpose+2 -> 62
    assert notes and notes[0].note_number == 62


# --- Detune ---


def test_pyxel_detune():
    source = "0: T120 L4 O4 Y10\n  C"
    ns, errors = _analyze_pyxel(source)
    notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
    assert notes and notes[0].detune_cents == 10.0


# --- Repeat ---


def test_pyxel_repeat_expansion():
    source = "0: T120 L4 O4\n[ C D ]2"
    ns, errors = _analyze_pyxel(source)
    notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
    assert len(notes) == 4


def test_pyxel_repeat_count3():
    source = "0: T120 L4 O4\n[ C D ]3"
    ns, errors = _analyze_pyxel(source)
    notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
    assert len(notes) == 6


def test_pyxel_nested_repeat():
    source = "0: T120 L4 O4\n[ [ C D ]2 E ]2"
    ns, errors = _analyze_pyxel(source)
    assert not any(e.severity == "error" for e in errors)
    notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
    # Inner: [C D]2 -> C D C D (4 notes)
    # Outer: [ ... E ]2 -> (C D C D E) * 2 = 10 notes
    assert len(notes) == 10


def test_pyxel_nested_repeat_count3():
    source = "0: T120 L4 O4\n[ [ C D ]2 E ]3"
    ns, errors = _analyze_pyxel(source)
    assert not any(e.severity == "error" for e in errors)
    notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
    # Inner: [C D]2 -> C D C D (4 notes)
    # Outer: [ ... E ]3 -> (C D C D E) * 3 = 15 notes
    assert len(notes) == 15


# --- Tie ---


def test_ppmck_tie_note():
    source = "A t120 l4 o4\n  c4 & c4"
    ns, errors = _analyze_ppmck(source)
    # Tie should merge notes; check no error
    assert not any(e.severity == "error" for e in errors)
    notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
    # c4 & c4 should produce a single note with doubled duration (192*2=384)
    assert len(notes) == 1
    assert notes[0].duration == 384


def test_pyxel_tie_note():
    source = "0: T120 L4 O4\n  C4 & C4"
    ns, errors = _analyze_pyxel(source)
    assert not any(e.severity == "error" for e in errors)
    notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
    # C4 & C4 should produce a single note with doubled duration (192*2=384)
    assert len(notes) == 1
    assert notes[0].duration == 384


def test_pyxel_tie_length():
    source = "0: T120 L4 O4\n  C4 & 4"
    ns, errors = _analyze_pyxel(source)
    notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
    # & 4 extends last note by l4 ticks
    assert notes and notes[0].duration > 192  # 192 + 48 = 240


# --- Octave up/down ---


def test_ppmck_octave_up():
    source = "A t120 l4 o4\n  c > d"
    ns, errors = _analyze_ppmck(source)
    notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
    assert notes[0].note_number == 60  # o4 c
    assert notes[1].note_number == 74  # o5 d


def test_pyxel_octave_down():
    source = "0: T120 L4 O4\n  C < D"
    ns, errors = _analyze_pyxel(source)
    notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
    assert notes[0].note_number == 60  # O4 C
    assert notes[1].note_number == 50  # O3 D


# --- Length with dots ---


def test_ppmck_dotted_note():
    source = "A t120 l4 o4\n  c."
    ns, errors = _analyze_ppmck(source)
    notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
    # l4 = 192 ticks, dotted = 192 + 96 = 288
    assert notes and notes[0].duration == 288


# --- Triangle volume ignored in ppmck ---


def test_ppmck_triangle_volume_ignored():
    source = "T t120 l4 o3 v7\n  c"
    ns, errors = _analyze_ppmck(source)
    notes = [e for e in ns.channels["Triangle"].events if isinstance(e, NoteEvent)]
    assert notes and notes[0].velocity == 15  # default, v ignored


# --- @ENV/@VIB/@GLI ---


def test_pyxel_env_event():
    source = "0: T120 L4 O4\n@ENV1 100 10\n  C"
    ns, errors = _analyze_pyxel(source)
    envs = [e for e in ns.channels["Pulse1"].events if isinstance(e, EnvelopeEvent)]
    assert envs and envs[0].slot == 1


def test_pyxel_vib_event():
    source = "0: T120 L4 O4\n@VIB1 10 20 5\n  C"
    ns, errors = _analyze_pyxel(source)
    from mml_composemusic_mcp.ir import VibratoEvent

    vibs = [e for e in ns.channels["Pulse1"].events if isinstance(e, VibratoEvent)]
    assert vibs and vibs[0].slot == 1


def test_pyxel_gli_event():
    source = "0: T120 L4 O4\n@GLI1 100 50\n  C"
    ns, errors = _analyze_pyxel(source)
    from mml_composemusic_mcp.ir import GlideEvent

    glis = [e for e in ns.channels["Pulse1"].events if isinstance(e, GlideEvent)]
    assert glis and glis[0].slot == 1


# --- Noise channel ---


def test_ppmck_noise_ignores_pitch():
    source = "N t120 l4 v10\n  c r"
    ns, errors = _analyze_ppmck(source)
    notes = [e for e in ns.channels["Noise"].events if isinstance(e, NoteEvent)]
    assert notes  # NoteEvent is created but with midi=0
    assert notes[0].note_number == 0  # pitch ignored