From bf36a773918959eeb6ffe7dae2fb8b0874438eb4 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sun, 12 Jul 2026 17:58:19 +0900 Subject: feat(ppmck): mckc準拠の構文拡張とIR保持コマンド追加 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @をデューティ比、qをクオンタイズに修正(mckc原本準拠) - ^タイ、Dディチューン、sスイープ、v+/v-相対音量を追加 - @v/@@/@MP/@EP/@EN エンベロープ・LFO系を構文解析してIR保持 - 上記IR保持コマンドは合成未対応を SEMANTIC_UNSUPPORTED_FEATURE で警告 - Design.md / MML_BNF.md を更新 - 全200テスト + ruff チェック通過 --- src/mml_composemusic_mcp/parser_ppmck.py | 305 ++++++++++++++++++++++++++++++- 1 file changed, 303 insertions(+), 2 deletions(-) (limited to 'src/mml_composemusic_mcp/parser_ppmck.py') diff --git a/src/mml_composemusic_mcp/parser_ppmck.py b/src/mml_composemusic_mcp/parser_ppmck.py index 892c167..47a0412 100644 --- a/src/mml_composemusic_mcp/parser_ppmck.py +++ b/src/mml_composemusic_mcp/parser_ppmck.py @@ -3,18 +3,36 @@ from .ast_nodes import ( ASTNode, BarStmt, + DetuneCmdStmt, + DutyEnvelopeDefStmt, + DutyEnvelopeUseStmt, DutyStmt, Header, LengthStmt, + LfoDefStmt, + LfoOffStmt, + LfoUseStmt, + NoteEnvDefStmt, + NoteEnvOffStmt, + NoteEnvUseStmt, NoteStmt, OctaveStmt, + PitchEnvDefStmt, + PitchEnvOffStmt, + PitchEnvUseStmt, Program, + QuantizeStmt, + RelativeVolumeStmt, RepeatEndStmt, RepeatStartStmt, RestStmt, + SweepStmt, TempoStmt, + TieCmdStmt, TieStmt, Track, + VolumeEnvelopeDefStmt, + VolumeEnvelopeUseStmt, VolumeStmt, ) from .ir import ErrorCode, ErrorDetail @@ -222,10 +240,14 @@ class PpmckParser: return self._parse_volume() if token.type == TokenType.DUTY: return self._parse_duty() + if token.type == TokenType.QUANTIZE: + return self._parse_quantize() if token.type == TokenType.TEMPO: return self._parse_tempo() if token.type == TokenType.TIE: return self._parse_tie() + if token.type == TokenType.TIE_CMD: + return self._parse_tie_cmd() if token.type == TokenType.REPEAT_START: return self._parse_repeat_start() if token.type == TokenType.REPEAT_END: @@ -233,6 +255,36 @@ class PpmckParser: if token.type == TokenType.BAR: self.ctx.advance() return BarStmt(line=token.line, column=token.column) + if token.type == TokenType.DETUNE: + return self._parse_detune() + if token.type == TokenType.SWEEP: + return self._parse_sweep() + if token.type == TokenType.REL_VOL_UP: + return self._parse_rel_vol(up=True) + if token.type == TokenType.REL_VOL_DOWN: + return self._parse_rel_vol(up=False) + if token.type == TokenType.VOL_ENV: + return self._parse_vol_env() + if token.type == TokenType.DUTY_ENV_USE: + return self._parse_duty_env_use() + if token.type == TokenType.LFO_DEF: + return self._parse_lfo_def() + if token.type == TokenType.LFO_USE: + return self._parse_lfo_use() + if token.type == TokenType.LFO_OFF: + return self._parse_lfo_off() + if token.type == TokenType.PITCH_ENV_DEF: + return self._parse_pitch_env_def() + if token.type == TokenType.PITCH_ENV_USE: + return self._parse_pitch_env_use() + if token.type == TokenType.PITCH_ENV_OFF: + return self._parse_pitch_env_off() + if token.type == TokenType.NOTE_ENV_DEF: + return self._parse_note_env_def() + if token.type == TokenType.NOTE_ENV_USE: + return self._parse_note_env_use() + if token.type == TokenType.NOTE_ENV_OFF: + return self._parse_note_env_off() # Unknown token in track context self.ctx.add_error( code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN, @@ -334,14 +386,27 @@ class PpmckParser: def _parse_duty(self) -> DutyStmt: token = self.ctx.advance() - self._require_value(token, "q") - value = int(token.value) if token.value else 0 + self._require_value(token, "@") + value = int(token.value) if token.value else 2 + # @ = { ... | ... } → duty envelope definition + if self.ctx.peek().type == TokenType.EQUAL: + return self._parse_duty_env_def_rest(token, value) return DutyStmt( line=token.line, column=token.column, value=value, ) + def _parse_quantize(self) -> QuantizeStmt: + token = self.ctx.advance() + self._require_value(token, "q") + value = int(token.value) if token.value else 8 + return QuantizeStmt( + line=token.line, + column=token.column, + value=value, + ) + def _parse_tempo(self) -> TempoStmt: token = self.ctx.advance() self._require_value(token, "t") @@ -391,6 +456,26 @@ class PpmckParser: ) return TieStmt(line=token.line, column=token.column, target=None) + def _parse_tie_cmd(self) -> TieCmdStmt: + """Parse ^ (tie) command.""" + token = self.ctx.advance() + next_token = self.ctx.peek() + if next_token.type == TokenType.NUMBER: + self.ctx.advance() + return TieCmdStmt( + line=token.line, + column=token.column, + target=int(next_token.value), + ) + if next_token.type == TokenType.NOTE: + return TieCmdStmt( + line=token.line, + column=token.column, + target=self._parse_note(), + ) + # ^ without target extends previous note duration + return TieCmdStmt(line=token.line, column=token.column, target=None) + def _parse_repeat_start(self) -> RepeatStartStmt: token = self.ctx.advance() return RepeatStartStmt(line=token.line, column=token.column) @@ -406,6 +491,222 @@ class PpmckParser: count=count, ) + def _parse_detune(self) -> DetuneCmdStmt: + token = self.ctx.advance() + self._require_value(token, "D") + value = int(token.value) if token.value else 0 + return DetuneCmdStmt( + line=token.line, + column=token.column, + value=value, + ) + + def _parse_sweep(self) -> SweepStmt: + token = self.ctx.advance() + parts = token.value.split(",") + speed = int(parts[0]) if len(parts) > 0 and parts[0] else 0 + depth = int(parts[1]) if len(parts) > 1 and parts[1] else 0 + return SweepStmt( + line=token.line, + column=token.column, + speed=speed, + depth=depth, + ) + + def _parse_rel_vol(self, up: bool) -> RelativeVolumeStmt: + token = self.ctx.advance() + value = int(token.value) if token.value else 1 + delta = value if up else -value + return RelativeVolumeStmt( + line=token.line, + column=token.column, + delta=delta, + ) + + def _parse_vol_env(self) -> ASTNode: + """@v → VolumeEnvelopeUseStmt or VolumeEnvelopeDefStmt.""" + token = self.ctx.advance() + self._require_value(token, "@v") + slot = int(token.value) if token.value else 0 + if self.ctx.peek().type == TokenType.EQUAL: + self.ctx.advance() # consume = + points, loop_points = self._parse_envelope_body() + return VolumeEnvelopeDefStmt( + line=token.line, + column=token.column, + slot=slot, + points=points, + loop_points=loop_points, + ) + return VolumeEnvelopeUseStmt( + line=token.line, + column=token.column, + slot=slot, + ) + + def _parse_duty_env_use(self) -> DutyEnvelopeUseStmt: + token = self.ctx.advance() + self._require_value(token, "@@") + slot = int(token.value) if token.value else 0 + return DutyEnvelopeUseStmt( + line=token.line, + column=token.column, + slot=slot, + ) + + def _parse_duty_env_def_rest(self, token: Token, slot: int) -> DutyEnvelopeDefStmt: + """@ = { ... | ... } → DutyEnvelopeDefStmt.""" + self.ctx.advance() # consume = + points, loop_points = self._parse_envelope_body() + return DutyEnvelopeDefStmt( + line=token.line, + column=token.column, + slot=slot, + points=points, + loop_points=loop_points, + ) + + def _parse_lfo_def(self) -> LfoDefStmt: + """@MP = { p1, p2, p3, p4 } → LfoDefStmt.""" + token = self.ctx.advance() + self._require_value(token, "@MP") + slot = int(token.value) if token.value else 0 + self.ctx.match(TokenType.EQUAL) + self.ctx.match(TokenType.BRACE_OPEN) + p1 = int(self.ctx.advance().value) + self.ctx.match(TokenType.COMMA) + p2 = int(self.ctx.advance().value) + self.ctx.match(TokenType.COMMA) + p3 = int(self.ctx.advance().value) + self.ctx.match(TokenType.COMMA) + p4 = int(self.ctx.advance().value) + self.ctx.match(TokenType.BRACE_CLOSE) + return LfoDefStmt( + line=token.line, + column=token.column, + slot=slot, + delay=p1, + speed=p2, + depth=p3, + transition=p4, + ) + + def _parse_lfo_use(self) -> LfoUseStmt: + token = self.ctx.advance() + slot = int(token.value) if token.value else 0 + return LfoUseStmt( + line=token.line, + column=token.column, + slot=slot, + ) + + def _parse_lfo_off(self) -> LfoOffStmt: + token = self.ctx.advance() + return LfoOffStmt(line=token.line, column=token.column) + + def _parse_pitch_env_def(self) -> PitchEnvDefStmt: + token = self.ctx.advance() + self._require_value(token, "@EP") + slot = int(token.value) if token.value else 0 + if self.ctx.peek().type == TokenType.EQUAL: + self.ctx.advance() + points, loop_points = self._parse_envelope_body() + return PitchEnvDefStmt( + line=token.line, + column=token.column, + slot=slot, + points=points, + loop_points=loop_points, + ) + return PitchEnvDefStmt( + line=token.line, + column=token.column, + slot=slot, + ) + + def _parse_pitch_env_use(self) -> PitchEnvUseStmt: + token = self.ctx.advance() + slot = int(token.value) if token.value else 0 + return PitchEnvUseStmt( + line=token.line, + column=token.column, + slot=slot, + ) + + def _parse_pitch_env_off(self) -> PitchEnvOffStmt: + token = self.ctx.advance() + return PitchEnvOffStmt(line=token.line, column=token.column) + + def _parse_note_env_def(self) -> NoteEnvDefStmt: + token = self.ctx.advance() + self._require_value(token, "@EN") + slot = int(token.value) if token.value else 0 + if self.ctx.peek().type == TokenType.EQUAL: + self.ctx.advance() + points, loop_points = self._parse_envelope_body() + return NoteEnvDefStmt( + line=token.line, + column=token.column, + slot=slot, + points=points, + loop_points=loop_points, + ) + return NoteEnvDefStmt( + line=token.line, + column=token.column, + slot=slot, + ) + + def _parse_note_env_use(self) -> NoteEnvUseStmt: + token = self.ctx.advance() + slot = int(token.value) if token.value else 0 + return NoteEnvUseStmt( + line=token.line, + column=token.column, + slot=slot, + ) + + def _parse_note_env_off(self) -> NoteEnvOffStmt: + token = self.ctx.advance() + return NoteEnvOffStmt(line=token.line, column=token.column) + + def _parse_envelope_body(self) -> tuple[list[int], list[int]]: + """Parse { num_list | num_list? } for envelope definitions.""" + self.ctx.match(TokenType.BRACE_OPEN) + points: list[int] = [] + loop_points: list[int] = [] + in_loop = False + while self.ctx.peek().type != TokenType.BRACE_CLOSE: + token = self.ctx.peek() + if token.type == TokenType.BAR: + self.ctx.advance() + in_loop = True + continue + if token.type == TokenType.COMMA: + self.ctx.advance() + continue + if token.type == TokenType.NUMBER: + self.ctx.advance() + val = int(token.value) + if in_loop: + loop_points.append(val) + else: + points.append(val) + continue + # Unexpected token inside envelope body + 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() + self.ctx.match(TokenType.BRACE_CLOSE) + return points, loop_points + def _handle_invalid(self, token: Token) -> None: self.ctx.add_invalid_token_error(token, context_line(self.source, token)) self.ctx.advance() -- cgit v1.2.3