summaryrefslogtreecommitdiff
path: root/src/mml_composemusic_mcp/parser_ppmck.py
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-07-12 17:58:19 +0900
committertanaka takashi <isazaka@gmail.com>2026-07-12 17:59:16 +0900
commitbf36a773918959eeb6ffe7dae2fb8b0874438eb4 (patch)
treeda7ff0037ea923fea255d2ed69ed2fb51395d9b9 /src/mml_composemusic_mcp/parser_ppmck.py
parentf1cd4d7d16f6e75a50bb0c587c0f759f7cf710cd (diff)
feat(ppmck): mckc準拠の構文拡張とIR保持コマンド追加
- @をデューティ比、qをクオンタイズに修正(mckc原本準拠) - ^タイ、Dディチューン、sスイープ、v+/v-相対音量を追加 - @v/@@/@MP/@EP/@EN エンベロープ・LFO系を構文解析してIR保持 - 上記IR保持コマンドは合成未対応を SEMANTIC_UNSUPPORTED_FEATURE で警告 - Design.md / MML_BNF.md を更新 - 全200テスト + ruff チェック通過
Diffstat (limited to 'src/mml_composemusic_mcp/parser_ppmck.py')
-rw-r--r--src/mml_composemusic_mcp/parser_ppmck.py305
1 files changed, 303 insertions, 2 deletions
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
+ # @<num> = { ... | ... } → 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<num> → 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:
+ """@<num> = { ... | ... } → 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<num> = { 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()