diff options
Diffstat (limited to 'src/mml_composemusic_mcp/parser_ppmck.py')
| -rw-r--r-- | src/mml_composemusic_mcp/parser_ppmck.py | 451 |
1 files changed, 451 insertions, 0 deletions
diff --git a/src/mml_composemusic_mcp/parser_ppmck.py b/src/mml_composemusic_mcp/parser_ppmck.py new file mode 100644 index 0000000..c2a1fa3 --- /dev/null +++ b/src/mml_composemusic_mcp/parser_ppmck.py @@ -0,0 +1,451 @@ +"""PPMCK-style MML parser.""" + +from .ir import ( + DutyEvent, + ErrorCode, + ErrorDetail, + NoteEvent, + NoteSequence, + RepeatEvent, + RestEvent, + TempoEvent, +) +from .lexer import Token, TokenType +from .parser_base import ( + ParserContext, + clamp, + length_value_to_ticks, + note_to_midi, +) + +CHANNEL_MAP = { + "A": "Pulse1", + "B": "Pulse2", + "T": "Triangle", + "N": "Noise", +} +PULSE_CHANNELS = {"Pulse1", "Pulse2"} + + +class PpmckParser: + 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 = 15 + self.duty = 2 + self.tick_position = 0 + self.headers: list[tuple[str, str]] = [] + self.loop_start: int | None = None + self.tracks_seen: set[str] = set() + + 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[NoteSequence, list[ErrorDetail]]: + while self.ctx.peek().type != TokenType.EOF: + token = self.ctx.peek() + if token.type == TokenType.HEADER: + self._parse_header() + continue + if token.type == TokenType.COMMENT: + self.ctx.advance() + continue + if token.type == TokenType.TRACK_HEADER: + self._parse_track_header() + continue + if self.current_channel is None: + self.ctx.advance() + continue + self._parse_statement() + + self._validate_tracks() + + if self.loop_start is not None: + max_tick = max( + ch.total_ticks for ch in self.note_sequence.channels.values() + ) + for ch in self.note_sequence.channels.values(): + ch.events.append( + RepeatEvent( + start_tick=self.loop_start, + end_tick=max_tick, + repeat_count=0, + ) + ) + + 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, + ) + + return self.note_sequence, self.ctx.errors + + def _parse_header(self) -> None: + token = self.ctx.advance() + raw = token.value + parts = raw.split(None, 1) + key = parts[0] if parts else raw + value = "" + if len(parts) > 1 and parts[1].startswith('"'): + value = parts[1].strip('"') + if not parts[1].endswith('"') or parts[1] == '"': + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNTERMINATED_HEADER, + line=token.line, + column=token.column, + message=f"ヘッダー '{key}' の引用符が閉じられていません。", + severity="error", + hint='二重引用符 " で値を閉じてください。例: #TITLE "My Song"', + context=self._context_line(token), + ) + self.headers.append((key, value)) + + def _parse_track_header(self) -> None: + token = self.ctx.advance() + ch = token.value.upper() + if ch == "L": + self.loop_start = 0 + return + if ch not in CHANNEL_MAP: + self.ctx.add_error( + code=ErrorCode.SYNTAX_INVALID_TRACK_HEADER, + line=token.line, + column=token.column, + message=f"無効なトラックヘッダー '{ch}' です。", + severity="error", + hint="A, B, T, N, L のいずれかを使用してください。", + context=self._context_line(token), + ) + self.current_channel = None + return + self.current_channel = CHANNEL_MAP[ch] + if self.current_channel in self.tracks_seen: + self.ctx.add_error( + code=ErrorCode.SYNTAX_DUPLICATE_TRACK, + line=token.line, + column=token.column, + message=f"トラック '{ch}' が複数回定義されています。", + severity="error", + hint="各トラックは1回のみ定義できます。重複定義を削除してください。", + context=self._context_line(token), + ) + self.tracks_seen.add(self.current_channel) + self.octave = 4 + self.default_length = 4 + self.velocity = 15 + self.duty = 2 + self.tick_position = 0 + + def _parse_statement(self) -> 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.DUTY: self._parse_duty, + TokenType.TEMPO: self._parse_tempo, + TokenType.TIE: self._parse_tie, + TokenType.REPEAT_START: self._parse_repeat_start, + TokenType.REPEAT_END: self._parse_repeat_end, + TokenType.BAR: self._parse_bar, + TokenType.COMMENT: lambda: self.ctx.advance(), + } + 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), + ) + self.ctx.advance() + + def _read_length(self) -> tuple[int, int]: + dots = 0 + length_token = self.ctx.match(TokenType.NUMBER) + if length_token is None: + length = self.default_length + else: + length = int(length_token.value) + while self.ctx.match(TokenType.DOT): + dots += 1 + return length, dots + + def _parse_note(self) -> None: + token = self.ctx.advance() + note = token.value.lower() + length, dots = self._read_length() + ticks = length_value_to_ticks(length, dots) + accidental = 0 + while True: + if self.ctx.match(TokenType.SHARP): + accidental += 1 + elif self.ctx.match(TokenType.FLAT): + accidental -= 1 + else: + break + + if self.current_channel == "Noise": + self.ctx.add_error( + code=ErrorCode.SYNTAX_CHANNEL_MISMATCH, + line=token.line, + column=token.column, + message="チャンネル 'Noise' で音高指定は使用できません。", + severity="warning", + hint="Noiseチャンネルでは音高ではなく音長と音量のみ指定してください。", + context=self._context_line(token), + ) + midi = 0 + else: + midi = note_to_midi(note, self.octave, accidental) + 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.velocity, + duty=self.duty, + gate_time=1.0, + ) + 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) + event = RestEvent(tick_position=self.tick_position, duration=ticks) + self._add_event(event) + self.tick_position += ticks + + def _parse_octave(self) -> None: + 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 + + def _parse_octave_up(self) -> None: + self.ctx.advance() + if self.octave < 7: + self.octave += 1 + + def _parse_octave_down(self) -> None: + self.ctx.advance() + if self.octave > 0: + self.octave -= 1 + + def _parse_length(self) -> None: + 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 + + def _parse_volume(self) -> None: + token = self.ctx.advance() + if self.current_channel == "Triangle": + # Silently ignored per spec + return + value = int(token.value) + if not 0 <= value <= 15: + self.ctx.add_error( + code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, + line=token.line, + column=token.column, + message=f"'v' の値 {value} は範囲外です。有効範囲: 0〜15。", + severity="error", + hint="例: v15 のように指定してください。", + context=self._context_line(token), + ) + return + self.velocity = value + + def _parse_duty(self) -> None: + token = self.ctx.advance() + 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}' で 'q' は使用できません。", + severity="warning", + hint="Triangleチャンネルはデューティ比を持ちません。q コマンドを削除してください。", + context=self._context_line(token), + ) + return + value = int(token.value) + if not 0 <= value <= 3: + self.ctx.add_error( + code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, + line=token.line, + column=token.column, + message=f"'q' の値 {value} は範囲外です。有効範囲: 0〜3。", + severity="error", + hint="例: q2 のように指定してください。", + context=self._context_line(token), + ) + return + self.duty = value + self._add_event(DutyEvent(tick_position=self.tick_position, value=value)) + + def _parse_tempo(self) -> None: + 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)) + + def _parse_tie(self) -> None: + 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)を続けてください。", + 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, parser will create next event; we just note that previous + # event should be merged. Simple approach: reduce tick_position and mark. + # Here we just allow the next note to overwrite position and merge later. + # Implementation: back up tick position so next note duration adds to previous. + self.tick_position = self._last_note_end() + + 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 + 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: + # Stage 2 feature: keep token but do not expand + token = self.ctx.advance() + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + line=token.line, + column=token.column, + message="区間リピート '[' は第2段階で実装されます。", + severity="warning", + hint="現時点では無視されます。", + context=self._context_line(token), + ) + + def _parse_repeat_end(self) -> None: + token = self.ctx.advance() + self.ctx.add_error( + code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, + line=token.line, + column=token.column, + message="区間リピート ']' は第2段階で実装されます。", + severity="warning", + hint="現時点では無視されます。", + context=self._context_line(token), + ) + + def _parse_bar(self) -> None: + 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: + # Determine approximate line of header (last track header token) + header_token = None + for tok in self.ctx.tokens: + if tok.type == TokenType.TRACK_HEADER and tok.value.upper() == 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 parse_ppmck( + source: str, tokens: list[Token] +) -> tuple[NoteSequence, list[ErrorDetail]]: + parser = PpmckParser(source, tokens) + return parser.parse() |
