summaryrefslogtreecommitdiff
path: root/src/mml_composemusic_mcp/parser_ppmck.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/mml_composemusic_mcp/parser_ppmck.py')
-rw-r--r--src/mml_composemusic_mcp/parser_ppmck.py451
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()