diff options
Diffstat (limited to 'src/mml_composemusic_mcp/parser_pyxel.py')
| -rw-r--r-- | src/mml_composemusic_mcp/parser_pyxel.py | 815 |
1 files changed, 263 insertions, 552 deletions
diff --git a/src/mml_composemusic_mcp/parser_pyxel.py b/src/mml_composemusic_mcp/parser_pyxel.py index f75c169..db9787d 100644 --- a/src/mml_composemusic_mcp/parser_pyxel.py +++ b/src/mml_composemusic_mcp/parser_pyxel.py @@ -1,26 +1,28 @@ -"""Pyxel MML parser.""" - -from .ir import ( - DutyEvent, - EnvelopeEvent, - ErrorCode, - ErrorDetail, - GlideEvent, - NoteEvent, - NoteSequence, - RepeatEvent, - RestEvent, - TempoEvent, - VibratoEvent, - VolumeEvent, +"""Pyxel MML parser: tokens -> AST.""" + +from .ast_nodes import ( + ASTNode, + BarStmt, + DetuneStmt, + DutyStmt, + ExtCmdStmt, + GateTimeStmt, + LengthStmt, + NoteStmt, + OctaveStmt, + Program, + RepeatEndStmt, + RepeatStartStmt, + RestStmt, + TempoStmt, + TieStmt, + Track, + TransposeStmt, + VolumeStmt, ) +from .ir import ErrorCode, ErrorDetail from .lexer import Token, TokenType -from .parser_base import ( - ParserContext, - clamp, - length_value_to_ticks, - note_to_midi, -) +from .parser_base import ParserContext, context_line CHANNEL_MAP = { "0": "Pulse1", @@ -28,95 +30,33 @@ CHANNEL_MAP = { "2": "Triangle", "3": "Noise", } -PULSE_CHANNELS = {"Pulse1", "Pulse2"} - -PYXEL_DUTY = { - "1": 2, # Square 50% - "2": 1, # Pulse 25% -} - - -class RepeatFrame: - """Stack frame for nested repeat expansion.""" - - def __init__(self, start_tick: int) -> None: - self.start_tick = start_tick - # Snapshot of event indices that existed before the repeat section - self.event_count: int = 0 class PyxelParser: def __init__(self, source: str, tokens: list[Token]) -> None: self.source = source self.ctx = ParserContext(tokens) - self.note_sequence = NoteSequence() - self.current_channel: str | None = None - self.octave = 4 - self.default_length = 4 - self.velocity = 100 - self.gate_time = 0.8 - self.duty = 2 - self.transpose = 0 - self.detune_cents = 0 - self.tick_position = 0 self.tracks_seen: set[str] = set() - self.repeat_stack: list[RepeatFrame] = [] - self._tie_pending: bool = False - def _context_line(self, token: Token) -> str: - lines = self.source.splitlines() - if 1 <= token.line <= len(lines): - return lines[token.line - 1] - return "" + def parse(self) -> tuple[Program, list[ErrorDetail]]: + program = Program(line=1, column=1, tracks=[]) - def parse(self) -> tuple[NoteSequence, list[ErrorDetail]]: while self.ctx.peek().type != TokenType.EOF: token = self.ctx.peek() if token.type == TokenType.TRACK_HEADER: - self._parse_track_header() + track = self._parse_track() + if track is not None: + program.tracks.append(track) continue - if self.current_channel is None: - self._warn_outside_track(token) - self.ctx.advance() + if token.type == TokenType.INVALID: + self._handle_invalid(token) continue - self._parse_statement() - - if self.repeat_stack: - for _frame in self.repeat_stack: - self.ctx.add_error( - code=ErrorCode.SYNTAX_UNTERMINATED_REPEAT, - line=1, - column=1, - message="リピート '[' に対応する ']' が見つかりません。", - severity="error", - hint="] を追加してリピートを閉じてください。回数指定(例: ]2)も可能です。", - context="", - ) - - self._validate_tracks() - - for ch in self.note_sequence.channels.values(): - ch.total_ticks = max( - (e.tick_position + getattr(e, "duration", 0) for e in ch.events), - default=0, - ) + self._warn_outside_track(token) + self.ctx.advance() - return self.note_sequence, self.ctx.errors + return program, self.ctx.errors - def _warn_outside_track(self, token: Token) -> None: - if token.type == TokenType.EOF: - return - self.ctx.add_error( - code=ErrorCode.SYNTAX_OUTSIDE_TRACK, - line=token.line, - column=token.column, - message=f"'{token.raw}' はトラック外に書かれています。", - severity="warning", - hint="コマンドはトラックヘッダー(0:, 1:, 2:, 3:)の後に記述してください。", - context=self._context_line(token), - ) - - def _parse_track_header(self) -> None: + def _parse_track(self) -> Track | None: token = self.ctx.advance() ch = token.value if ch not in CHANNEL_MAP: @@ -127,12 +67,11 @@ class PyxelParser: message=f"無効なトラックヘッダー '{ch}:' です。", severity="error", hint="0:, 1:, 2:, 3: のいずれかを使用してください。", - context=self._context_line(token), + context=context_line(self.source, token), ) - self.current_channel = None - return - self.current_channel = CHANNEL_MAP[ch] - if self.current_channel in self.tracks_seen: + return None + channel = CHANNEL_MAP[ch] + if channel in self.tracks_seen: self.ctx.add_error( code=ErrorCode.SYNTAX_DUPLICATE_TRACK, line=token.line, @@ -140,70 +79,91 @@ class PyxelParser: message=f"トラック '{ch}:' が複数回定義されています。", severity="error", hint="各トラックは1回のみ定義できます。重複定義を削除してください。", - context=self._context_line(token), + context=context_line(self.source, token), ) - self.tracks_seen.add(self.current_channel) - self.octave = 4 - self.default_length = 4 - self.velocity = 100 - self.gate_time = 0.8 - self.duty = 2 - self.transpose = 0 - self.detune_cents = 0 - self.tick_position = 0 - - def _parse_statement(self) -> None: + self.tracks_seen.add(channel) + track = Track( + line=token.line, + column=token.column, + track_id=ch, + channel=channel, + mode="pyxel", + statements=[], + ) + while self.ctx.peek().type not in ( + TokenType.TRACK_HEADER, + TokenType.EOF, + ): + stmt = self._parse_statement() + if stmt is not None: + track.statements.append(stmt) + return track + + def _parse_statement(self) -> ASTNode | None: token = self.ctx.peek() - handlers = { - TokenType.NOTE: self._parse_note, - TokenType.REST: self._parse_rest, - TokenType.OCTAVE: self._parse_octave, - TokenType.OCTAVE_UP: self._parse_octave_up, - TokenType.OCTAVE_DOWN: self._parse_octave_down, - TokenType.LENGTH: self._parse_length, - TokenType.VOLUME: self._parse_volume, - TokenType.GATE_TIME: self._parse_gate_time, - TokenType.AT: self._parse_at, - TokenType.TEMPO: self._parse_tempo, - TokenType.TRANSPOSE: self._parse_transpose, - TokenType.DETUNE: self._parse_detune, - TokenType.TIE: self._parse_tie, - TokenType.REPEAT_START: self._parse_repeat_start, - TokenType.REPEAT_END: self._parse_repeat_end, - TokenType.EXT_CMD: self._parse_ext_cmd, - TokenType.BAR: self._parse_bar, - } - handler = handlers.get(token.type) - if handler: - handler() - else: - self.ctx.add_error( - code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, - line=token.line, - column=token.column, - message=f"'{token.raw}' はここでは使用できません。", - severity="error", - hint="音符、休符、コマンド、小節線を配置してください。", - context=self._context_line(token), - ) + if token.type == TokenType.INVALID: + self._handle_invalid(token) + return None + if token.type == TokenType.NOTE: + return self._parse_note() + if token.type == TokenType.REST: + return self._parse_rest() + if token.type == TokenType.OCTAVE: + return self._parse_octave() + if token.type == TokenType.OCTAVE_UP: + return self._parse_octave_up() + if token.type == TokenType.OCTAVE_DOWN: + return self._parse_octave_down() + if token.type == TokenType.LENGTH: + return self._parse_length() + if token.type == TokenType.VOLUME: + return self._parse_volume() + if token.type == TokenType.GATE_TIME: + return self._parse_gate_time() + if token.type == TokenType.AT: + return self._parse_at() + if token.type == TokenType.TEMPO: + return self._parse_tempo() + if token.type == TokenType.TRANSPOSE: + return self._parse_transpose() + if token.type == TokenType.DETUNE: + return self._parse_detune() + if token.type == TokenType.TIE: + return self._parse_tie() + if token.type == TokenType.REPEAT_START: + return self._parse_repeat_start() + if token.type == TokenType.REPEAT_END: + return self._parse_repeat_end() + if token.type == TokenType.EXT_CMD: + return self._parse_ext_cmd() + if token.type == TokenType.BAR: self.ctx.advance() + return BarStmt(line=token.line, column=token.column) + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + line=token.line, + column=token.column, + message=f"'{token.raw}' はここでは使用できません。", + severity="error", + hint="音符、休符、コマンド、小節線を配置してください。", + context=context_line(self.source, token), + ) + self.ctx.advance() + return None - def _read_length(self) -> tuple[int, int]: - dots = 0 + def _read_length(self, token: Token) -> tuple[int | None, int]: length_token = self.ctx.match(TokenType.NUMBER) - if length_token is None: - length = self.default_length - else: + length: int | None = None + if length_token is not None: length = int(length_token.value) + dots = 0 while self.ctx.match(TokenType.DOT): dots += 1 return length, dots - def _parse_note(self) -> None: + def _parse_note(self) -> NoteStmt: token = self.ctx.advance() - note = token.value.lower() - length, dots = self._read_length() - ticks = length_value_to_ticks(length, dots) + length, dots = self._read_length(token) accidental = 0 while True: if self.ctx.match(TokenType.SHARP): @@ -212,454 +172,205 @@ class PyxelParser: accidental -= 1 else: break - - if self._tie_pending: - # Tie: extend the last note/rest's duration instead of creating a new event - self._extend_last_note(ticks) - self.tick_position += ticks - self._tie_pending = False - return - - if self.current_channel == "Noise": - midi = note_to_midi(note, self.octave, accidental) - else: - midi = note_to_midi(note, self.octave, accidental) + self.transpose - if not 0 <= midi <= 127: - self.ctx.add_error( - code=ErrorCode.SYNTAX_NOTE_OUT_OF_RANGE, - line=token.line, - column=token.column, - message=f"音高が範囲外です(MIDI音番号 {midi})。", - severity="warning", - hint="オクターブ/トランスポーズを調整してください。有効範囲: O0〜O7。", - context=self._context_line(token), - ) - midi = clamp(midi, 0, 127) - - event = NoteEvent( - tick_position=self.tick_position, - duration=ticks, - note_number=midi, - velocity=self._normalize_velocity(self.velocity), - duty=self.duty, - gate_time=self.gate_time, - detune_cents=float(self.detune_cents), + return NoteStmt( + line=token.line, + column=token.column, + note_name=token.value.lower(), + accidental=accidental, + length=length, + dots=dots, ) - self._add_event(event) - self.tick_position += ticks - def _parse_rest(self) -> None: - self.ctx.advance() - length, dots = self._read_length() - ticks = length_value_to_ticks(length, dots) - if self._tie_pending: - self._extend_last_note(ticks) - self.tick_position += ticks - self._tie_pending = False - return - event = RestEvent(tick_position=self.tick_position, duration=ticks) - self._add_event(event) - self.tick_position += ticks - - def _parse_octave(self) -> None: + def _parse_rest(self) -> RestStmt: token = self.ctx.advance() - value = int(token.value) - if not 0 <= value <= 7: - self.ctx.add_error( - code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, - line=token.line, - column=token.column, - message=f"'O' の値 {value} は範囲外です。有効範囲: 0〜7。", - severity="error", - hint="例: O4 のように指定してください。", - context=self._context_line(token), - ) - return - self.octave = value + length, dots = self._read_length(token) + return RestStmt( + line=token.line, + column=token.column, + length=length, + dots=dots, + ) - def _parse_octave_up(self) -> None: - self.ctx.advance() - if self.octave < 7: - self.octave += 1 + def _parse_octave(self) -> OctaveStmt: + token = self.ctx.advance() + value = int(token.value) if token.value else 4 + return OctaveStmt( + line=token.line, + column=token.column, + value=value, + direction=None, + ) - def _parse_octave_down(self) -> None: - self.ctx.advance() - if self.octave > 0: - self.octave -= 1 + def _parse_octave_up(self) -> OctaveStmt: + token = self.ctx.advance() + return OctaveStmt( + line=token.line, + column=token.column, + value=None, + direction="up", + ) - def _parse_length(self) -> None: + def _parse_octave_down(self) -> OctaveStmt: token = self.ctx.advance() - value = int(token.value) - if not 1 <= value <= 192: - self.ctx.add_error( - code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, - line=token.line, - column=token.column, - message=f"'L' の値 {value} は範囲外です。有効範囲: 1〜192。", - severity="error", - hint="例: L8 のように指定してください。", - context=self._context_line(token), - ) - return - self.default_length = value + return OctaveStmt( + line=token.line, + column=token.column, + value=None, + direction="down", + ) - def _parse_volume(self) -> None: + def _parse_length(self) -> LengthStmt: token = self.ctx.advance() - value = int(token.value) - if not 0 <= value <= 127: - self.ctx.add_error( - code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, - line=token.line, - column=token.column, - message=f"'V' の値 {value} は範囲外です。有効範囲: 0〜127。", - severity="error", - hint="例: V100 のように指定してください。", - context=self._context_line(token), - ) - return - self.velocity = value - self._add_event( - VolumeEvent( - tick_position=self.tick_position, - value=self._normalize_velocity(value), - ) + value = int(token.value) if token.value else 4 + return LengthStmt( + line=token.line, + column=token.column, + value=value, ) - def _normalize_velocity(self, value: int) -> int: - return round(value / 127 * 15) + def _parse_volume(self) -> VolumeStmt: + token = self.ctx.advance() + value = int(token.value) if token.value else 0 + return VolumeStmt( + line=token.line, + column=token.column, + value=value, + ) - def _parse_gate_time(self) -> None: + def _parse_gate_time(self) -> GateTimeStmt: token = self.ctx.advance() - value = int(token.value) - if not 0 <= value <= 100: - self.ctx.add_error( - code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, - line=token.line, - column=token.column, - message=f"'Q' の値 {value} は範囲外です。有効範囲: 0〜100。", - severity="error", - hint="例: Q80 のように指定してください。", - context=self._context_line(token), - ) - return - self.gate_time = value / 100.0 + value = int(token.value) if token.value else 80 + return GateTimeStmt( + line=token.line, + column=token.column, + value=value, + ) - def _parse_at(self) -> None: + def _parse_at(self) -> DutyStmt: token = self.ctx.advance() - value = token.value - if value in PYXEL_DUTY: - if self.current_channel not in PULSE_CHANNELS: - self.ctx.add_error( - code=ErrorCode.SYNTAX_CHANNEL_MISMATCH, - line=token.line, - column=token.column, - message=f"チャンネル '{self.current_channel}' で '@{value}' は使用できません。", - severity="warning", - hint="トラック番号がチャンネルを決定します。@ コマンドを削除するか、Pulseチャンネル(0: または 1:)に移動してください。", - context=self._context_line(token), - ) - return - self.duty = PYXEL_DUTY[value] - self._add_event( - DutyEvent(tick_position=self.tick_position, value=self.duty) - ) - return - if value == "0": - self.ctx.add_error( - code=ErrorCode.SYNTAX_CHANNEL_MISMATCH, - line=token.line, - column=token.column, - message=f"チャンネル '{self.current_channel}' で '@0' (Triangle) は使用できません。", - severity="warning", - hint="トラック番号がチャンネルを決定します。@ コマンドを削除するか、適切なチャンネルに移動してください。", - context=self._context_line(token), - ) - elif value == "3": - self.ctx.add_error( - code=ErrorCode.SYNTAX_CHANNEL_MISMATCH, - line=token.line, - column=token.column, - message=f"チャンネル '{self.current_channel}' で '@3' (Noise) は使用できません。", - severity="warning", - hint="トラック番号がチャンネルを決定します。@ コマンドを削除するか、適切なチャンネルに移動してください。", - context=self._context_line(token), - ) - else: - self.ctx.add_error( - code=ErrorCode.SYNTAX_INVALID_NUMBER, - line=token.line, - column=token.column, - message=f"'@' の後に無効な値 '{value}' が見つかりました。", - severity="error", - hint="例: @1 のように指定してください。", - context=self._context_line(token), - ) + value = int(token.value) if token.value else 0 + return DutyStmt( + line=token.line, + column=token.column, + value=value, + ) - def _parse_tempo(self) -> None: + def _parse_tempo(self) -> TempoStmt: token = self.ctx.advance() - value = int(token.value) - if value < 1: - self.ctx.add_error( - code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, - line=token.line, - column=token.column, - message=f"'T' の値 {value} は範囲外です。有効範囲: 1以上。", - severity="error", - hint="例: T150 のように指定してください。", - context=self._context_line(token), - ) - return - self.note_sequence.bpm = value - self._add_event(TempoEvent(tick_position=self.tick_position, bpm=value)) + value = int(token.value) if token.value else 120 + return TempoStmt( + line=token.line, + column=token.column, + value=value, + ) - def _parse_transpose(self) -> None: + def _parse_transpose(self) -> TransposeStmt: token = self.ctx.advance() - self.transpose = int(token.value) + value = int(token.value) if token.value else 0 + return TransposeStmt( + line=token.line, + column=token.column, + value=value, + ) - def _parse_detune(self) -> None: + def _parse_detune(self) -> DetuneStmt: token = self.ctx.advance() - self.detune_cents = int(token.value) + value = int(token.value) if token.value else 0 + return DetuneStmt( + line=token.line, + column=token.column, + value=value, + ) - def _parse_tie(self) -> None: + def _parse_tie(self) -> TieStmt: token = self.ctx.advance() next_token = self.ctx.peek() - if next_token.type not in (TokenType.NOTE, TokenType.REST, TokenType.NUMBER): - self.ctx.add_error( - code=ErrorCode.SYNTAX_UNTERMINATED_TIE, - line=token.line, - column=token.column, - message="タイ '&' の後に音符が見つかりません。", - severity="error", - hint="& の後に音符(例: C4)を続けてください。音長のみ(例: &16)も可能です。", - context=self._context_line(token), - ) - return if next_token.type == TokenType.NUMBER: self.ctx.advance() - ticks = length_value_to_ticks(int(next_token.value), 0) - self._extend_last_note(ticks) - self.tick_position += ticks - return - # For note/rest: back up tick_position so the next event starts at the - # end of the last note/rest. The next note's duration will be added - # to the previous note's duration via _tie_pending flag. - self.tick_position = self._last_note_end() - self._tie_pending = True - - def _last_note_end(self) -> int: - ch = self.note_sequence.channels[self.current_channel] # type: ignore[index] - for ev in reversed(ch.events): - if isinstance(ev, (NoteEvent, RestEvent)): - return ev.tick_position + ev.duration - return self.tick_position - - def _extend_last_note(self, ticks: int) -> None: - ch = self.note_sequence.channels[self.current_channel] # type: ignore[index] - for ev in reversed(ch.events): - if isinstance(ev, (NoteEvent, RestEvent)): - ev.duration += ticks - return - - def _parse_repeat_start(self) -> None: - self.ctx.advance() - ch = self.note_sequence.channels[self.current_channel] # type: ignore[index] - frame = RepeatFrame(start_tick=self.tick_position) - frame.event_count = len(ch.events) - self.repeat_stack.append(frame) - - def _parse_repeat_end(self) -> None: - token = self.ctx.advance() - if not self.repeat_stack: - self.ctx.add_error( - code=ErrorCode.SYNTAX_UNMATCHED_REPEAT_END, + return TieStmt( line=token.line, column=token.column, - message="']' に対応する '[' が見つかりません。", - severity="error", - hint="直前の [ を確認するか、余分な ] を削除してください。", - context=self._context_line(token), + target=int(next_token.value), ) - return - frame = self.repeat_stack.pop() - start_tick = frame.start_tick - ch = self.note_sequence.channels[self.current_channel] # type: ignore[index] - - count_str = token.value - if count_str == "": - count = 0 # infinite - self.ctx.add_error( - code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + if next_token.type == TokenType.NOTE: + return TieStmt( line=token.line, column=token.column, - message="無限リピートはWAV生成時に2回で打ち切られます。", - severity="warning", - hint="回数を指定するか、2回の再生で十分であることを確認してください。", - context=self._context_line(token), + target=self._parse_note(), ) - else: - count = int(count_str) - - # Collect events that belong to this repeat section (created after the frame snapshot) - segment_events = ch.events[frame.event_count :] - segment_length = self.tick_position - start_tick - - repeat_count = 2 if count == 0 else count - for i in range(1, repeat_count): - offset = i * segment_length - for ev in segment_events: - new_event = self._clone_event(ev, ev.tick_position + offset) - self._add_event(new_event) - if isinstance(new_event, (NoteEvent, RestEvent)): - end = new_event.tick_position + new_event.duration - if end > self.tick_position: - self.tick_position = end - - ch.events.sort(key=lambda e: e.tick_position) - self._add_event( - RepeatEvent( - start_tick=start_tick, - end_tick=self.tick_position, - repeat_count=count, + if next_token.type == TokenType.REST: + return TieStmt( + line=token.line, + column=token.column, + target=self._parse_rest(), ) + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNTERMINATED_TIE, + line=token.line, + column=token.column, + message="タイ '&' の後に音符が見つかりません。", + severity="error", + hint="& の後に音符(例: C4)を続けてください。音長のみ(例: &16)も可能です。", + context=context_line(self.source, token), ) + return TieStmt(line=token.line, column=token.column, target=None) - def _clone_event(self, event, tick_position: int): # type: ignore[no-untyped-def] - import copy + def _parse_repeat_start(self) -> RepeatStartStmt: + token = self.ctx.advance() + return RepeatStartStmt(line=token.line, column=token.column) - new = copy.copy(event) - new.tick_position = tick_position - return new + def _parse_repeat_end(self) -> RepeatEndStmt: + token = self.ctx.advance() + count: int | None = None + if token.value: + count = int(token.value) + return RepeatEndStmt( + line=token.line, + column=token.column, + count=count, + ) - def _parse_ext_cmd(self) -> None: + def _parse_ext_cmd(self) -> ExtCmdStmt: token = self.ctx.advance() - cmd = token.value # ENV, VIB, GLI + cmd = token.value slot_token = self.ctx.match(TokenType.NUMBER) - if slot_token is None: - self.ctx.add_error( - code=ErrorCode.SYNTAX_INVALID_NUMBER, - line=token.line, - column=token.column, - message=f"'@{cmd}' の後にスロット番号が必要です。", - severity="error", - hint=f"例: @{cmd}1 のように指定してください。", - context=self._context_line(token), - ) - return - slot = int(slot_token.value) - if slot == 0: - # OFF - if cmd == "ENV": - ev = EnvelopeEvent(tick_position=self.tick_position, slot=0) - elif cmd == "VIB": - ev = VibratoEvent(tick_position=self.tick_position, slot=0) - else: - ev = GlideEvent(tick_position=self.tick_position, slot=0) - self._add_event(ev) - return - if self.ctx.match(TokenType.NUMBER): - # definition without braces; simplistic parse numbers until non-number - # Real Pyxel uses { ... }; we accept a flat sequence of ints for now. - values = [int(slot_token.value)] - while True: - num = self.ctx.match(TokenType.NUMBER) - if num is None: - break - values.append(int(num.value)) - if cmd == "ENV": - points = [] - for i in range(1, len(values), 2): - duration = values[i] - target = values[i - 1] if i - 1 < len(values) else values[-1] - points.append({"target_volume": target, "duration_ticks": duration}) - ev = EnvelopeEvent( - tick_position=self.tick_position, - slot=slot, - points=points, - ) - elif cmd == "VIB": - if len(values) >= 3: - params = { - "delay_ticks": values[0], - "period_ticks": values[1], - "depth_cents": values[2], - } - else: - params = {} - ev = VibratoEvent( - tick_position=self.tick_position, - slot=slot, - params=params, - ) - else: - if len(values) >= 2: - params = { - "initial_offset_cents": values[0], - "duration_ticks": values[1], - } - else: - params = {} - ev = GlideEvent( - tick_position=self.tick_position, - slot=slot, - params=params, - ) - self._add_event(ev) - self.ctx.add_error( - code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, - line=token.line, - column=token.column, - message=f"@{cmd} は第1段階では未サポート、無視されます。", - severity="warning", - hint="合成には反映されませんが、IRとして保持されます。", - context=self._context_line(token), - ) - return - # Slot switch only - if cmd == "ENV": - ev = EnvelopeEvent(tick_position=self.tick_position, slot=slot) - elif cmd == "VIB": - ev = VibratoEvent(tick_position=self.tick_position, slot=slot) - else: - ev = GlideEvent(tick_position=self.tick_position, slot=slot) - self._add_event(ev) - self.ctx.add_error( - code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + slot = 0 + if slot_token is not None: + slot = int(slot_token.value) + params: list[int] = [] + while True: + num = self.ctx.match(TokenType.NUMBER) + if num is None: + break + params.append(int(num.value)) + return ExtCmdStmt( line=token.line, column=token.column, - message=f"@{cmd} は第1段階では未サポート、無視されます。", - severity="warning", - hint="合成には反映されませんが、IRとして保持されます。", - context=self._context_line(token), + cmd=cmd, + slot=slot, + params=params, ) - def _parse_bar(self) -> None: + def _handle_invalid(self, token: Token) -> None: + self.ctx.add_invalid_token_error(token, context_line(self.source, token)) self.ctx.advance() - def _add_event(self, event) -> None: # type: ignore[no-untyped-def] - ch = self.note_sequence.channels[self.current_channel] # type: ignore[index] - ch.events.append(event) - - def _validate_tracks(self) -> None: - for raw, name in CHANNEL_MAP.items(): - ch = self.note_sequence.channels[name] - if name in self.tracks_seen and not ch.events: - header_token = None - for tok in self.ctx.tokens: - if tok.type == TokenType.TRACK_HEADER and tok.value == raw: - header_token = tok - self.ctx.add_error( - code=ErrorCode.SYNTAX_EMPTY_TRACK, - line=header_token.line if header_token else 1, - column=header_token.column if header_token else 1, - message=f"トラック '{raw}:' に音符または休符がありません。", - severity="error", - hint="少なくとも1つの音符または休符を記述してください。", - context=self._context_line(header_token) if header_token else "", - ) + def _warn_outside_track(self, token: Token) -> None: + if token.type == TokenType.EOF: + return + self.ctx.add_error( + code=ErrorCode.SEMANTIC_OUTSIDE_TRACK, + line=token.line, + column=token.column, + message=f"'{token.raw}' はトラック外に書かれています。", + severity="warning", + hint="コマンドはトラックヘッダー(0:, 1:, 2:, 3:)の後に記述してください。", + context=context_line(self.source, token), + ) def parse_pyxel( source: str, tokens: list[Token] -) -> tuple[NoteSequence, list[ErrorDetail]]: +) -> tuple[Program, list[ErrorDetail]]: parser = PyxelParser(source, tokens) return parser.parse() |
