summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-07-15 20:00:24 +0900
committertanaka takashi <isazaka@gmail.com>2026-07-15 20:00:24 +0900
commit7249ca3a6fdf51f6b7ea66ac6854edd5e02ae5ec (patch)
tree37a42621228a6f7a87ef02ae08d4613b2151a1a5 /src
parent9cbb60cf68ab2a8bd9ccc0dca4fbd46f2ae9b928 (diff)
feat: MMLパーサーと意味解析を厳格化
- ppmckの終端分岐付きリピートとL制御トラックを実装 - case、構文構造、音長、エフェクト値域の検証を強化 - Pyxel拡張コマンドのarityと参照検証を追加 - リピート展開上限と構文・意味エラー時の合成停止を追加 - BNF、設計書、適合監査、回帰テストを更新
Diffstat (limited to 'src')
-rw-r--r--src/mml_composemusic_mcp/ast_nodes.py8
-rw-r--r--src/mml_composemusic_mcp/lexer.py46
-rw-r--r--src/mml_composemusic_mcp/parser_base.py5
-rw-r--r--src/mml_composemusic_mcp/parser_ppmck.py230
-rw-r--r--src/mml_composemusic_mcp/parser_pyxel.py38
-rw-r--r--src/mml_composemusic_mcp/semantic_base.py190
-rw-r--r--src/mml_composemusic_mcp/semantic_ppmck.py214
-rw-r--r--src/mml_composemusic_mcp/semantic_pyxel.py239
8 files changed, 676 insertions, 294 deletions
diff --git a/src/mml_composemusic_mcp/ast_nodes.py b/src/mml_composemusic_mcp/ast_nodes.py
index dc85e79..5eedbbc 100644
--- a/src/mml_composemusic_mcp/ast_nodes.py
+++ b/src/mml_composemusic_mcp/ast_nodes.py
@@ -161,6 +161,13 @@ class RepeatStartStmt(ASTNode):
@dataclass
+class RepeatBreakStmt(ASTNode):
+ """Last-pass break marker (|) inside a ppmck repeat."""
+
+ pass
+
+
+@dataclass
class RepeatEndStmt(ASTNode):
"""End of repeat section (] with optional count)."""
@@ -333,6 +340,7 @@ Statement = (
| DetuneCmdStmt
| RelativeVolumeStmt
| RepeatStartStmt
+ | RepeatBreakStmt
| RepeatEndStmt
| BarStmt
| ExtCmdStmt
diff --git a/src/mml_composemusic_mcp/lexer.py b/src/mml_composemusic_mcp/lexer.py
index ed0a26e..dbc55ce 100644
--- a/src/mml_composemusic_mcp/lexer.py
+++ b/src/mml_composemusic_mcp/lexer.py
@@ -208,10 +208,6 @@ class Lexer:
self._advance()
self._emit(TokenType.SHARP, "+", "+")
return
- if ch == "#":
- self._advance()
- self._emit(TokenType.SHARP, "#", "#")
- return
if ch == "-":
self._advance()
self._emit(TokenType.FLAT, "-", "-")
@@ -295,9 +291,8 @@ class Lexer:
return
cmd = self._advance()
- lower = cmd.lower()
- if lower in "olvt":
- if lower == "v" and self._peek() in "+-":
+ if cmd in "olvt":
+ if cmd == "v" and self._peek() in "+-":
sign = self._advance()
num = self._read_number()
raw = cmd + sign + num
@@ -308,16 +303,16 @@ class Lexer:
return
num = self._read_number()
raw = cmd + num
- if lower == "o":
+ if cmd == "o":
self._emit(TokenType.OCTAVE, num, raw)
- elif lower == "l":
+ elif cmd == "l":
self._emit(TokenType.LENGTH, num, raw)
- elif lower == "v":
+ elif cmd == "v":
self._emit(TokenType.VOLUME, num, raw)
- elif lower == "t":
+ elif cmd == "t":
self._emit(TokenType.TEMPO, num, raw)
return
- if lower == "q":
+ if cmd == "q":
num = self._read_number()
self._emit(TokenType.QUANTIZE, num, "q" + num)
return
@@ -325,20 +320,20 @@ class Lexer:
num = self._read_signed_number()
self._emit(TokenType.DETUNE, num, "D" + num)
return
- if lower == "s":
+ if cmd == "s":
+ raw_start = self.pos - 1
num0 = self._read_number()
if self._peek() == ",":
self._advance()
num1 = self._read_signed_number()
- raw = f"s{num0},{num1}"
+ raw = self.source[raw_start : self.pos]
self._emit(TokenType.SWEEP, f"{num0},{num1}", raw)
return
- note = lower
- if note in "cdefgabr":
- if note == "r":
- self._emit(TokenType.REST, note, note)
+ if cmd in "cdefgabr":
+ if cmd == "r":
+ self._emit(TokenType.REST, cmd, cmd)
else:
- self._emit(TokenType.NOTE, note, note)
+ self._emit(TokenType.NOTE, cmd, cmd)
return
# Unknown command: emit as invalid token for parser to report
self._emit(TokenType.INVALID, cmd, cmd)
@@ -402,8 +397,8 @@ class Lexer:
return
if ch == "@":
self._advance()
- if self.source[self.pos : self.pos + 3].upper() in ("ENV", "VIB", "GLI"):
- ext = self.source[self.pos : self.pos + 3].upper()
+ if self.source[self.pos : self.pos + 3] in ("ENV", "VIB", "GLI"):
+ ext = self.source[self.pos : self.pos + 3]
self.pos += 3
self.column += 3
raw = "@" + ext
@@ -432,12 +427,11 @@ class Lexer:
elif cmd == "Y":
self._emit(TokenType.DETUNE, num, raw)
return
- note = cmd.upper()
- if note in "CDEFGABR":
- if note == "R":
- self._emit(TokenType.REST, note, note)
+ if cmd in "CDEFGABR":
+ if cmd == "R":
+ self._emit(TokenType.REST, cmd, cmd)
else:
- self._emit(TokenType.NOTE, note, note)
+ self._emit(TokenType.NOTE, cmd, cmd)
return
self._emit(TokenType.INVALID, cmd, cmd)
return
diff --git a/src/mml_composemusic_mcp/parser_base.py b/src/mml_composemusic_mcp/parser_base.py
index 506a33e..2625c5d 100644
--- a/src/mml_composemusic_mcp/parser_base.py
+++ b/src/mml_composemusic_mcp/parser_base.py
@@ -33,6 +33,11 @@ class ParserContext:
def expect(self, *types: TokenType) -> Token | None:
return self.match(*types)
+ def synchronize(self, *types: TokenType) -> None:
+ """Advance to a recovery token without ever consuming past EOF."""
+ while self.peek().type not in (*types, TokenType.EOF):
+ self.advance()
+
def add_error(
self,
code: ErrorCode,
diff --git a/src/mml_composemusic_mcp/parser_ppmck.py b/src/mml_composemusic_mcp/parser_ppmck.py
index bd3b06b..5b8de40 100644
--- a/src/mml_composemusic_mcp/parser_ppmck.py
+++ b/src/mml_composemusic_mcp/parser_ppmck.py
@@ -23,6 +23,7 @@ from .ast_nodes import (
Program,
QuantizeStmt,
RelativeVolumeStmt,
+ RepeatBreakStmt,
RepeatEndStmt,
RepeatStartStmt,
RestStmt,
@@ -65,6 +66,9 @@ class PpmckParser:
self.ctx = ParserContext(tokens)
self.tracks_seen: set[str] = set()
self.track_ids_seen: set[str] = set()
+ self.repeat_stack: list[Token] = []
+ self.repeat_break_seen: list[bool] = []
+ self.seen_global_definition = False
def parse(self) -> tuple[Program, list[ErrorDetail]]:
program = Program(line=1, column=1, tracks=[])
@@ -73,7 +77,7 @@ class PpmckParser:
while self.ctx.peek().type != TokenType.EOF:
token = self.ctx.peek()
if token.type == TokenType.HEADER:
- if program.tracks:
+ if program.tracks or self.seen_global_definition:
self.ctx.add_error(
code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
line=token.line,
@@ -116,9 +120,10 @@ class PpmckParser:
),
):
program.global_statements.append(statement)
+ self.seen_global_definition = True
else:
self.ctx.add_error(
- code=ErrorCode.SEMANTIC_OUTSIDE_TRACK,
+ code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
line=token.line,
column=token.column,
message="トラック外ではエフェクト定義だけを使用できます。",
@@ -128,7 +133,7 @@ class PpmckParser:
)
continue
# Anything else before a track header is outside a track.
- self._warn_outside_track(token)
+ self._error_outside_track(token)
self.ctx.advance()
# Attach headers to the first track if no track exists yet
@@ -195,7 +200,7 @@ class PpmckParser:
def _parse_track(self) -> Track | None:
token = self.ctx.advance()
- ch = token.value.upper()
+ ch = token.value
if ch in self.track_ids_seen:
self.ctx.add_error(
code=ErrorCode.SYNTAX_DUPLICATE_TRACK,
@@ -207,16 +212,7 @@ class PpmckParser:
context=context_line(self.source, token),
)
self.track_ids_seen.add(ch)
- if ch == "L":
- return Track(
- line=token.line,
- column=token.column,
- track_id="L",
- channel="Loop",
- mode="ppmck",
- statements=[],
- )
- if ch not in CHANNEL_MAP:
+ if ch not in (*CHANNEL_MAP, "L"):
self.ctx.add_error(
code=ErrorCode.SYNTAX_INVALID_TRACK_HEADER,
line=token.line,
@@ -227,7 +223,7 @@ class PpmckParser:
context=context_line(self.source, token),
)
return None
- channel = CHANNEL_MAP[ch]
+ channel = "Loop" if ch == "L" else CHANNEL_MAP[ch]
self.tracks_seen.add(channel)
track = Track(
line=token.line,
@@ -237,13 +233,48 @@ class PpmckParser:
mode="ppmck",
statements=[],
)
+ self.repeat_stack = []
+ self.repeat_break_seen = []
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)
+ if channel == "Loop" and not isinstance(
+ stmt,
+ (
+ LengthStmt,
+ RestStmt,
+ RepeatStartStmt,
+ RepeatBreakStmt,
+ RepeatEndStmt,
+ BarStmt,
+ ),
+ ):
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
+ line=stmt.line,
+ column=stmt.column,
+ message="Lトラックでは時間指定以外のコマンドを使用できません。",
+ severity="error",
+ hint="l、休符、リピート、小節線だけを使用してください。",
+ context=context_line(self.source, stmt),
+ )
+ else:
+ track.statements.append(stmt)
+ for start in self.repeat_stack:
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNTERMINATED_REPEAT,
+ line=start.line,
+ column=start.column,
+ message="リピート '[' に対応する ']' が見つかりません。",
+ severity="error",
+ hint="] を追加してリピートを閉じてください。",
+ context=context_line(self.source, start),
+ )
+ self.repeat_stack = []
+ self.repeat_break_seen = []
return track
def _parse_statement(self) -> ASTNode | None:
@@ -284,6 +315,19 @@ class PpmckParser:
return self._parse_repeat_end()
if token.type == TokenType.BAR:
self.ctx.advance()
+ if self.repeat_stack:
+ if self.repeat_break_seen[-1]:
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
+ line=token.line,
+ column=token.column,
+ message="1つのリピート内に終端分岐 '|' は1つだけ指定できます。",
+ severity="error",
+ hint="余分な '|' を削除してください。",
+ context=context_line(self.source, token),
+ )
+ self.repeat_break_seen[-1] = True
+ return RepeatBreakStmt(line=token.line, column=token.column)
return BarStmt(line=token.line, column=token.column)
if token.type == TokenType.DETUNE:
return self._parse_detune()
@@ -448,7 +492,7 @@ class PpmckParser:
)
def _require_value(self, token: Token, command: str) -> None:
- if not token.value:
+ if token.value in ("", "+", "-"):
self.ctx.add_missing_number_error(
token, command, context_line(self.source, token)
)
@@ -503,15 +547,38 @@ class PpmckParser:
column=token.column,
target=self._parse_note(),
)
- # ^ without target extends previous note duration
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNTERMINATED_TIE,
+ line=token.line,
+ column=token.column,
+ message="タイ '^' の後に音符または音長が必要です。",
+ severity="error",
+ hint="^16 または ^c4 のように対象を続けてください。",
+ context=context_line(self.source, token),
+ )
return TieCmdStmt(line=token.line, column=token.column, target=None)
def _parse_repeat_start(self) -> RepeatStartStmt:
token = self.ctx.advance()
+ self.repeat_stack.append(token)
+ self.repeat_break_seen.append(False)
return RepeatStartStmt(line=token.line, column=token.column)
def _parse_repeat_end(self) -> RepeatEndStmt:
token = self.ctx.advance()
+ if not self.repeat_stack:
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNMATCHED_REPEAT_END,
+ line=token.line,
+ column=token.column,
+ message="']' に対応する '[' が見つかりません。",
+ severity="error",
+ hint="余分な ']' を削除してください。",
+ context=context_line(self.source, token),
+ )
+ else:
+ self.repeat_stack.pop()
+ self.repeat_break_seen.pop()
count: int | None = None
if token.value:
count = int(token.value)
@@ -524,7 +591,7 @@ class PpmckParser:
def _parse_detune(self) -> DetuneCmdStmt:
token = self.ctx.advance()
self._require_value(token, "D")
- value = int(token.value) if token.value else 0
+ value = int(token.value) if token.value not in ("", "+", "-") else 0
return DetuneCmdStmt(
line=token.line,
column=token.column,
@@ -534,8 +601,15 @@ class PpmckParser:
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
+ valid_parts = (
+ len(parts) == 2 and parts[0].isdigit() and parts[1].lstrip("+-").isdigit()
+ )
+ if "," not in token.raw or not valid_parts:
+ self.ctx.add_missing_number_error(
+ token, "s<speed>,<depth>", context_line(self.source, token)
+ )
+ speed = int(parts[0]) if valid_parts else 0
+ depth = int(parts[1]) if valid_parts else 0
return SweepStmt(
line=token.line,
column=token.column,
@@ -601,24 +675,22 @@ class PpmckParser:
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)
+ self._expect_token(TokenType.EQUAL, "'='")
+ self._expect_token(TokenType.BRACE_OPEN, "'{'")
+ values: list[int] = []
+ for index in range(4):
+ values.append(self._expect_number("@MPのパラメータ"))
+ if index < 3:
+ self._expect_token(TokenType.COMMA, "','")
+ self._expect_token(TokenType.BRACE_CLOSE, "'}'")
return LfoDefStmt(
line=token.line,
column=token.column,
slot=slot,
- delay=p1,
- speed=p2,
- depth=p3,
- transition=p4,
+ delay=values[0],
+ speed=values[1],
+ depth=values[2],
+ transition=values[3],
)
def _parse_lfo_use(self) -> LfoUseStmt:
@@ -640,7 +712,7 @@ class PpmckParser:
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()
+ points, loop_points = self._parse_envelope_body(signed=True)
return PitchEnvDefStmt(
line=token.line,
column=token.column,
@@ -673,7 +745,7 @@ class PpmckParser:
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()
+ points, loop_points = self._parse_envelope_body(signed=True)
return NoteEnvDefStmt(
line=token.line,
column=token.column,
@@ -700,16 +772,34 @@ class PpmckParser:
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)
+ def _parse_envelope_body(
+ self, *, signed: bool = False
+ ) -> tuple[list[int], list[int]]:
+ """Parse a non-empty envelope body with an optional loop divider."""
+ if self._expect_token(TokenType.BRACE_OPEN, "'{'") is None:
+ self.ctx.synchronize(TokenType.BRACE_CLOSE, TokenType.TRACK_HEADER)
+ self.ctx.match(TokenType.BRACE_CLOSE)
+ return [], []
points: list[int] = []
loop_points: list[int] = []
in_loop = False
- while self.ctx.peek().type != TokenType.BRACE_CLOSE:
+ while self.ctx.peek().type not in (
+ TokenType.BRACE_CLOSE,
+ TokenType.TRACK_HEADER,
+ TokenType.EOF,
+ ):
token = self.ctx.peek()
if token.type == TokenType.BAR:
self.ctx.advance()
+ if in_loop:
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
+ line=token.line,
+ column=token.column,
+ message="エンベロープのループ区切り '|' は1つだけ指定できます。",
+ severity="error",
+ context=context_line(self.source, token),
+ )
in_loop = True
continue
if token.type == TokenType.COMMA:
@@ -723,6 +813,18 @@ class PpmckParser:
else:
points.append(val)
continue
+ if signed and token.type in (TokenType.SHARP, TokenType.FLAT):
+ sign = token.raw
+ self.ctx.advance()
+ number = self.ctx.match(TokenType.NUMBER)
+ if sign not in ("+", "-") or number is None:
+ self.ctx.add_missing_number_error(
+ token, "符号付き値", context_line(self.source, token)
+ )
+ continue
+ val = int(number.value) * (-1 if sign == "-" else 1)
+ (loop_points if in_loop else points).append(val)
+ continue
# Unexpected token inside envelope body
self.ctx.add_error(
code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
@@ -734,22 +836,60 @@ class PpmckParser:
context=context_line(self.source, token),
)
self.ctx.advance()
- self.ctx.match(TokenType.BRACE_CLOSE)
+ if not points:
+ token = self.ctx.peek()
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
+ line=token.line,
+ column=token.column,
+ message="エンベロープ定義には1つ以上の値が必要です。",
+ severity="error",
+ context=context_line(self.source, token),
+ )
+ self._expect_token(TokenType.BRACE_CLOSE, "'}'")
return points, loop_points
+ def _expect_token(self, token_type: TokenType, label: str) -> Token | None:
+ token = self.ctx.match(token_type)
+ if token is not None:
+ return token
+ actual = self.ctx.peek()
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
+ line=actual.line,
+ column=actual.column,
+ message=f"{label} が必要ですが '{actual.raw}' が見つかりました。",
+ severity="error",
+ hint=f"{label} を正しい位置に追加してください。",
+ context=context_line(self.source, actual),
+ )
+ return None
+
+ def _expect_number(self, label: str) -> int:
+ token = self.ctx.match(TokenType.NUMBER)
+ if token is not None:
+ return int(token.value)
+ actual = self.ctx.peek()
+ self.ctx.add_missing_number_error(
+ actual, label, context_line(self.source, actual)
+ )
+ if actual.type not in (TokenType.EOF, TokenType.TRACK_HEADER):
+ self.ctx.advance()
+ return 0
+
def _handle_invalid(self, token: Token) -> None:
self.ctx.add_invalid_token_error(token, context_line(self.source, token))
self.ctx.advance()
- def _warn_outside_track(self, token: Token) -> None:
+ def _error_outside_track(self, token: Token) -> None:
if token.type in (TokenType.EOF, TokenType.COMMENT):
return
self.ctx.add_error(
- code=ErrorCode.SEMANTIC_OUTSIDE_TRACK,
+ code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
line=token.line,
column=token.column,
message=f"'{token.raw}' はトラック外に書かれています。",
- severity="warning",
+ severity="error",
hint="コマンドはトラックヘッダー(A, B, T, N)の後に記述してください。",
context=context_line(self.source, token),
)
diff --git a/src/mml_composemusic_mcp/parser_pyxel.py b/src/mml_composemusic_mcp/parser_pyxel.py
index 06cb613..a4fa7e1 100644
--- a/src/mml_composemusic_mcp/parser_pyxel.py
+++ b/src/mml_composemusic_mcp/parser_pyxel.py
@@ -37,6 +37,7 @@ class PyxelParser:
self.source = source
self.ctx = ParserContext(tokens)
self.tracks_seen: set[str] = set()
+ self.repeat_stack: list[Token] = []
def parse(self) -> tuple[Program, list[ErrorDetail]]:
program = Program(line=1, column=1, tracks=[])
@@ -51,7 +52,7 @@ class PyxelParser:
if token.type == TokenType.INVALID:
self._handle_invalid(token)
continue
- self._warn_outside_track(token)
+ self._error_outside_track(token)
self.ctx.advance()
return program, self.ctx.errors
@@ -90,6 +91,7 @@ class PyxelParser:
mode="pyxel",
statements=[],
)
+ self.repeat_stack = []
while self.ctx.peek().type not in (
TokenType.TRACK_HEADER,
TokenType.EOF,
@@ -97,6 +99,17 @@ class PyxelParser:
stmt = self._parse_statement()
if stmt is not None:
track.statements.append(stmt)
+ for start in self.repeat_stack:
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNTERMINATED_REPEAT,
+ line=start.line,
+ column=start.column,
+ message="リピート '[' に対応する ']' が見つかりません。",
+ severity="error",
+ hint="] を追加してリピートを閉じてください。",
+ context=context_line(self.source, start),
+ )
+ self.repeat_stack = []
return track
def _parse_statement(self) -> ASTNode | None:
@@ -331,10 +344,23 @@ class PyxelParser:
def _parse_repeat_start(self) -> RepeatStartStmt:
token = self.ctx.advance()
+ self.repeat_stack.append(token)
return RepeatStartStmt(line=token.line, column=token.column)
def _parse_repeat_end(self) -> RepeatEndStmt:
token = self.ctx.advance()
+ if not self.repeat_stack:
+ self.ctx.add_error(
+ code=ErrorCode.SYNTAX_UNMATCHED_REPEAT_END,
+ line=token.line,
+ column=token.column,
+ message="']' に対応する '[' が見つかりません。",
+ severity="error",
+ hint="余分な ']' を削除してください。",
+ context=context_line(self.source, token),
+ )
+ else:
+ self.repeat_stack.pop()
count: int | None = None
if token.value:
count = int(token.value)
@@ -351,6 +377,10 @@ class PyxelParser:
slot = 0
if slot_token is not None:
slot = int(slot_token.value)
+ else:
+ self.ctx.add_missing_number_error(
+ token, f"@{cmd}", context_line(self.source, token)
+ )
params: list[int] = []
while True:
num = self.ctx.match(TokenType.NUMBER)
@@ -369,15 +399,15 @@ class PyxelParser:
self.ctx.add_invalid_token_error(token, context_line(self.source, token))
self.ctx.advance()
- def _warn_outside_track(self, token: Token) -> None:
+ def _error_outside_track(self, token: Token) -> None:
if token.type == TokenType.EOF:
return
self.ctx.add_error(
- code=ErrorCode.SEMANTIC_OUTSIDE_TRACK,
+ code=ErrorCode.SYNTAX_UNEXPECTED_TOKEN,
line=token.line,
column=token.column,
message=f"'{token.raw}' はトラック外に書かれています。",
- severity="warning",
+ severity="error",
hint="コマンドはトラックヘッダー(0:, 1:, 2:, 3:)の後に記述してください。",
context=context_line(self.source, token),
)
diff --git a/src/mml_composemusic_mcp/semantic_base.py b/src/mml_composemusic_mcp/semantic_base.py
index 0a6acdb..b55a221 100644
--- a/src/mml_composemusic_mcp/semantic_base.py
+++ b/src/mml_composemusic_mcp/semantic_base.py
@@ -27,6 +27,7 @@ from .ast_nodes import (
Program,
QuantizeStmt,
RelativeVolumeStmt,
+ RepeatBreakStmt,
RepeatEndStmt,
RepeatStartStmt,
RestStmt,
@@ -47,10 +48,13 @@ from .ir import (
ErrorPhase,
NoteEvent,
NoteSequence,
+ RepeatEvent,
RestEvent,
)
from .parser_base import length_value_to_ticks
+MAX_EXPANDED_EVENTS = 100_000
+
@dataclass
class SemanticContext:
@@ -95,6 +99,7 @@ class SemanticAnalyzer:
self.gate_time = 1.0
self.duty = 2
self.tick_position = 0
+ self.repeat_tick_stack: list[int] = []
def analyze(self) -> tuple[NoteSequence, list[ErrorDetail]]:
self.current_channel = None
@@ -111,7 +116,10 @@ class SemanticAnalyzer:
if track.channel == "Loop":
self._handle_loop_track(track)
return
- for stmt in track.statements:
+ statements = self._expand_repeats(track.statements)
+ if statements is None:
+ return
+ for stmt in statements:
self._analyze_statement(stmt)
def _reset_state(self, track: Track) -> None:
@@ -121,6 +129,7 @@ class SemanticAnalyzer:
self.gate_time = 1.0
self.duty = 2
self.tick_position = 0
+ self.repeat_tick_stack = []
def _handle_loop_track(self, track: Track) -> None:
# Loop track is handled after all tracks are processed.
@@ -166,6 +175,8 @@ class SemanticAnalyzer:
self._analyze_relative_volume(stmt)
elif isinstance(stmt, RepeatStartStmt):
self._analyze_repeat_start(stmt)
+ elif isinstance(stmt, RepeatBreakStmt):
+ pass
elif isinstance(stmt, RepeatEndStmt):
self._analyze_repeat_end(stmt)
elif isinstance(stmt, BarStmt):
@@ -203,10 +214,52 @@ class SemanticAnalyzer:
else:
self._analyze_stage2(stmt)
- def _ticks_for(self, stmt: NoteStmt | RestStmt) -> int:
+ def _ticks_for(self, stmt: NoteStmt | RestStmt) -> int | None:
length = stmt.length if stmt.length is not None else self.default_length
+ if not 1 <= length <= 192:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"音長 {length} は範囲外です。有効範囲: 1〜192。",
+ severity="error",
+ hint="1〜192の音長を指定してください。",
+ context=self._context_line(stmt),
+ )
+ return None
return length_value_to_ticks(length, stmt.dots)
+ def _ticks_for_value(self, value: int, stmt: ASTNode) -> int | None:
+ if not 1 <= value <= 192:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ line=stmt.line,
+ column=stmt.column,
+ message=f"タイ音長 {value} は範囲外です。有効範囲: 1〜192。",
+ severity="error",
+ hint="1〜192の音長を指定してください。",
+ context=self._context_line(stmt),
+ )
+ return None
+ return length_value_to_ticks(value, 0)
+
+ def _validate_range(
+ self, stmt: ASTNode, value: int, minimum: int, maximum: int, label: str
+ ) -> bool:
+ if minimum <= value <= maximum:
+ return True
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ line=stmt.line,
+ column=stmt.column,
+ message=(
+ f"{label}の値 {value} は範囲外です。有効範囲: {minimum}〜{maximum}。"
+ ),
+ severity="error",
+ context=self._context_line(stmt),
+ )
+ return False
+
def _analyze_note(self, stmt: NoteStmt) -> None:
raise NotImplementedError
@@ -247,10 +300,20 @@ class SemanticAnalyzer:
raise NotImplementedError
def _analyze_repeat_start(self, stmt: RepeatStartStmt) -> None:
- raise NotImplementedError
+ self.repeat_tick_stack.append(self.tick_position)
def _analyze_repeat_end(self, stmt: RepeatEndStmt) -> None:
- raise NotImplementedError
+ if not self.repeat_tick_stack:
+ return
+ start_tick = self.repeat_tick_stack.pop()
+ self._add_event(
+ RepeatEvent(
+ tick_position=self.tick_position,
+ start_tick=start_tick,
+ end_tick=self.tick_position,
+ repeat_count=0 if stmt.count is None else stmt.count,
+ )
+ )
def _analyze_ext_cmd(self, stmt: ExtCmdStmt) -> None:
raise NotImplementedError
@@ -331,3 +394,122 @@ class SemanticAnalyzer:
if 1 <= node.line <= len(lines):
return lines[node.line - 1]
return ""
+
+ def _expand_repeats(self, statements: list[Statement]) -> list[Statement] | None:
+ """Expand nested repeats transactionally before semantic state updates."""
+ initial_error_count = sum(e.severity == "error" for e in self.ctx.errors)
+
+ def segment(index: int) -> tuple[list[Statement], int, str | None]:
+ result: list[Statement] = []
+ while index < len(statements):
+ stmt = statements[index]
+ if isinstance(stmt, RepeatBreakStmt):
+ return result, index + 1, "break"
+ if isinstance(stmt, RepeatEndStmt):
+ return result, index + 1, "end"
+ if not isinstance(stmt, RepeatStartStmt):
+ result.append(stmt)
+ index += 1
+ continue
+
+ prefix, index, terminator = segment(index + 1)
+ suffix: list[Statement] = []
+ if terminator == "break":
+ suffix, index, terminator = segment(index)
+ if terminator != "end":
+ return result, index, terminator
+
+ end_stmt = statements[index - 1]
+ assert isinstance(end_stmt, RepeatEndStmt)
+ count = end_stmt.count
+ if count is not None and count < 1:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ line=end_stmt.line,
+ column=end_stmt.column,
+ message="リピート回数は1以上で指定してください。",
+ severity="error",
+ context=self._context_line(end_stmt),
+ )
+ return [], len(statements), None
+
+ full = prefix + suffix
+ prefix_cost = sum(self._event_cost(item) for item in prefix)
+ full_cost = sum(self._event_cost(item) for item in full)
+ if count is None:
+ body_cost = full_cost * 2
+ elif suffix:
+ body_cost = full_cost * (count - 1) + prefix_cost
+ else:
+ body_cost = prefix_cost * count
+ projected = (
+ sum(self._event_cost(item) for item in result)
+ + body_cost
+ + self._event_cost(end_stmt)
+ )
+ if projected > MAX_EXPANDED_EVENTS:
+ self._add_repeat_limit_error(end_stmt, projected)
+ return [], len(statements), None
+ expanded_body: list[Statement] = []
+ if count is None:
+ expanded_body = full * 2
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
+ line=end_stmt.line,
+ column=end_stmt.column,
+ message="無限リピートはWAV生成用IRで2回に展開されます。",
+ severity="warning",
+ hint="IRのRepeatEventにはrepeat_count=0を保持します。",
+ context=self._context_line(end_stmt),
+ )
+ elif suffix:
+ expanded_body = full * (count - 1) + prefix
+ else:
+ expanded_body = prefix * count
+ result.extend([stmt, *expanded_body, end_stmt])
+ return result, index, None
+
+ expanded, _, _ = segment(0)
+ if sum(e.severity == "error" for e in self.ctx.errors) > initial_error_count:
+ return None
+ projected = sum(self._event_cost(stmt) for stmt in expanded)
+ if projected > MAX_EXPANDED_EVENTS:
+ node = next(
+ (
+ stmt
+ for stmt in reversed(statements)
+ if isinstance(stmt, RepeatEndStmt)
+ ),
+ statements[-1] if statements else ASTNode(line=1, column=1),
+ )
+ self._add_repeat_limit_error(node, projected)
+ return None
+ return expanded
+
+ def _add_repeat_limit_error(self, node: ASTNode, projected: int) -> None:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ line=node.line,
+ column=node.column,
+ message=(
+ f"リピート展開後のイベント数 {projected} が上限 "
+ f"{MAX_EXPANDED_EVENTS} を超えます。"
+ ),
+ severity="error",
+ hint="リピート回数またはリピート内のイベント数を減らしてください。",
+ context=self._context_line(node),
+ )
+
+ def _event_cost(self, stmt: Statement) -> int:
+ no_event = (
+ BarStmt,
+ LengthStmt,
+ OctaveStmt,
+ GateTimeStmt,
+ TransposeStmt,
+ TieStmt,
+ TieCmdStmt,
+ RepeatStartStmt,
+ RepeatBreakStmt,
+ )
+ return 0 if isinstance(stmt, no_event) else 1
diff --git a/src/mml_composemusic_mcp/semantic_ppmck.py b/src/mml_composemusic_mcp/semantic_ppmck.py
index 4171a4b..d736a53 100644
--- a/src/mml_composemusic_mcp/semantic_ppmck.py
+++ b/src/mml_composemusic_mcp/semantic_ppmck.py
@@ -54,7 +54,7 @@ from .ir import (
VolumeEnvelopeEvent,
VolumeEvent,
)
-from .parser_base import clamp, length_value_to_ticks, note_to_midi
+from .parser_base import clamp, note_to_midi
from .semantic_base import SemanticAnalyzer
PULSE_CHANNELS = {"Pulse1", "Pulse2"}
@@ -67,51 +67,75 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
self.loop_start: int | None = None
self.detune_cents = 0.0
- def _define(self, kind: str, slot: int, value: dict) -> None:
+ def _define(self, kind: str, slot: int, value: dict, stmt) -> bool: # type: ignore[no-untyped-def]
+ if not self._validate_range(stmt, slot, 0, 255, "エフェクトスロット"):
+ return False
table = self.note_sequence.definitions[kind]
if slot in table:
self.ctx.add_error(
ErrorCode.SEMANTIC_DUPLICATE_DEFINITION,
- 0,
- 0,
+ stmt.line,
+ stmt.column,
f"エフェクトスロット {slot} は既に定義されています。",
"error",
"別のスロット番号を使用してください。",
+ self._context_line(stmt),
)
- return
+ return False
table[slot] = value
+ return True
+
+ def _validate_values(
+ self,
+ stmt,
+ values: list[int],
+ minimum: int,
+ maximum: int,
+ label: str, # type: ignore[no-untyped-def]
+ ) -> bool:
+ return all(
+ self._validate_range(stmt, value, minimum, maximum, label)
+ for value in values
+ )
+
+ def _use_defined(self, stmt, kind: str, slot: int) -> bool: # type: ignore[no-untyped-def]
+ if not self._validate_range(stmt, slot, 0, 255, "エフェクトスロット"):
+ return False
+ if slot in self.note_sequence.definitions[kind]:
+ return True
+ self.ctx.add_error(
+ ErrorCode.SEMANTIC_UNDEFINED_REFERENCE,
+ stmt.line,
+ stmt.column,
+ f"未定義のエフェクトスロット {slot} です。",
+ "error",
+ "先に同じ番号の定義を追加してください。",
+ self._context_line(stmt),
+ )
+ return False
def analyze(self) -> tuple[NoteSequence, list[ErrorDetail]]:
ns, errors = super().analyze()
- references = (
- (VolumeEnvelopeEvent, "volume_envelopes"),
- (DutyEnvelopeEvent, "duty_envelopes"),
- (LfoEvent, "lfos"),
- (PitchEnvEvent, "pitch_envelopes"),
- (NoteEnvEvent, "note_envelopes"),
- )
- for channel in ns.channels.values():
- for event in channel.events:
- for event_type, table_name in references:
- if (
- isinstance(event, event_type)
- and not event.is_definition
- and not getattr(event, "is_off", False)
- and event.slot not in ns.definitions[table_name]
- ):
- self.ctx.add_error(
- ErrorCode.SEMANTIC_UNDEFINED_REFERENCE,
- 0,
- 0,
- f"未定義のエフェクトスロット {event.slot} です。",
- "error",
- "先に同じ番号の定義を追加してください。",
- )
if self.loop_start is not None:
max_tick = max(ch.total_ticks for ch in ns.channels.values())
+ if self.loop_start >= max_tick:
+ loop_track = next(
+ track for track in self.program.tracks if track.channel == "Loop"
+ )
+ self.ctx.add_error(
+ ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ loop_track.line,
+ loop_track.column,
+ f"Lトラックのloop_start {self.loop_start} は曲長 {max_tick} 以上です。",
+ "error",
+ "Lトラックの時間を音声トラックの曲長より短くしてください。",
+ self._context_line(loop_track),
+ )
+ return ns, errors
for ch in ns.channels.values():
ch.events.append(
RepeatEvent(
+ tick_position=max_tick,
start_tick=self.loop_start,
end_tick=max_tick,
repeat_count=0,
@@ -134,14 +158,23 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
self.tick_position = 0
self.detune_cents = 0.0
- def _analyze_track(self, track: Track) -> None:
- if track.channel == "Loop":
- self.loop_start = 0
+ def _handle_loop_track(self, track: Track) -> None:
+ statements = self._expand_repeats(track.statements)
+ if statements is None:
return
- super()._analyze_track(track)
+ for stmt in statements:
+ if isinstance(stmt, LengthStmt):
+ self._analyze_length(stmt)
+ elif isinstance(stmt, RestStmt):
+ ticks = self._ticks_for(stmt)
+ if ticks is not None:
+ self.tick_position += ticks
+ self.loop_start = self.tick_position
def _analyze_note(self, stmt: NoteStmt) -> None:
ticks = self._ticks_for(stmt)
+ if ticks is None:
+ return
midi = note_to_midi(stmt.note_name, self.octave, stmt.accidental)
if self.current_channel == "Noise":
self.ctx.add_error(
@@ -179,6 +212,8 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
def _analyze_rest(self, stmt: RestStmt) -> None:
ticks = self._ticks_for(stmt)
+ if ticks is None:
+ return
event = RestEvent(tick_position=self.tick_position, duration=ticks)
self._add_event(event)
self.tick_position += ticks
@@ -187,10 +222,14 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
if stmt.direction == "up":
if self.octave < 7:
self.octave += 1
+ else:
+ self._validate_range(stmt, self.octave + 1, 0, 7, "オクターブ")
return
if stmt.direction == "down":
if self.octave > 0:
self.octave -= 1
+ else:
+ self._validate_range(stmt, self.octave - 1, 0, 7, "オクターブ")
return
if stmt.value is None:
return
@@ -222,9 +261,6 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
self.default_length = stmt.value
def _analyze_volume(self, stmt: VolumeStmt) -> None:
- if self.current_channel == "Triangle":
- # Silently ignored per spec
- return
if not 0 <= stmt.value <= 15:
self.ctx.add_error(
code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
@@ -236,29 +272,22 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
context=self._context_line(stmt),
)
return
+ if self.current_channel == "Triangle":
+ return
self.velocity = stmt.value
self._add_event(VolumeEvent(tick_position=self.tick_position, value=stmt.value))
def _analyze_duty(self, stmt: DutyStmt) -> None:
+ if not self._validate_range(stmt, stmt.value, 0, 3, "デューティ比"):
+ return
if self.current_channel not in PULSE_CHANNELS:
self.ctx.add_error(
code=ErrorCode.SEMANTIC_CHANNEL_MISMATCH,
line=stmt.line,
column=stmt.column,
message=f"チャンネル '{self.current_channel}' で '@' は使用できません。",
- severity="warning",
- hint="Triangleチャンネルはデューティ比を持ちません。@ コマンドを削除してください。",
- context=self._context_line(stmt),
- )
- return
- if not 0 <= stmt.value <= 3:
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
- line=stmt.line,
- column=stmt.column,
- message=f"'@' の値 {stmt.value} は範囲外です。有効範囲: 0〜3。",
severity="error",
- hint="例: @2 のように指定してください。",
+ hint="Triangleチャンネルはデューティ比を持ちません。@ コマンドを削除してください。",
context=self._context_line(stmt),
)
return
@@ -286,21 +315,29 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
if stmt.target is None:
return
if isinstance(stmt.target, int):
- ticks = length_value_to_ticks(stmt.target, 0)
+ ticks = self._ticks_for_value(stmt.target, stmt)
+ if ticks is None:
+ return
self._extend_last_note(ticks)
self.tick_position += ticks
return
if isinstance(stmt.target, NoteStmt):
target_ticks = self._ticks_for(stmt.target)
+ if target_ticks is None:
+ return
self._extend_last_note(target_ticks)
self.tick_position += target_ticks
return
def _analyze_detune_cmd(self, stmt: DetuneCmdStmt) -> None:
+ if not self._validate_range(stmt, stmt.value, -127, 126, "ディチューン"):
+ return
self.detune_cents = float(stmt.value)
self._add_event(DetuneEvent(tick_position=self.tick_position, value=stmt.value))
def _analyze_relative_volume(self, stmt: RelativeVolumeStmt) -> None:
+ if not self._validate_range(stmt, abs(stmt.delta), 1, 15, "相対音量"):
+ return
self.velocity = clamp(self.velocity + stmt.delta, 0, 15)
self._add_event(
RelativeVolumeEvent(tick_position=self.tick_position, delta=stmt.delta)
@@ -337,16 +374,24 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
)
def _analyze_vol_env_use(self, stmt: VolumeEnvelopeUseStmt) -> None:
+ if not self._use_defined(stmt, "volume_envelopes", stmt.slot):
+ return
self._add_event(
VolumeEnvelopeEvent(tick_position=self.tick_position, slot=stmt.slot)
)
def _analyze_vol_env_def(self, stmt: VolumeEnvelopeDefStmt) -> None:
- self._define(
+ if not self._validate_values(
+ stmt, stmt.points + stmt.loop_points, 0, 15, "音量エンベロープ"
+ ):
+ return
+ if not self._define(
"volume_envelopes",
stmt.slot,
{"points": stmt.points, "loop_points": stmt.loop_points},
- )
+ stmt,
+ ):
+ return
if self.current_channel is not None:
self._add_event(
VolumeEnvelopeEvent(
@@ -359,11 +404,17 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
)
def _analyze_duty_env_def(self, stmt: DutyEnvelopeDefStmt) -> None:
- self._define(
+ if not self._validate_values(
+ stmt, stmt.points + stmt.loop_points, 0, 3, "デューティエンベロープ"
+ ):
+ return
+ if not self._define(
"duty_envelopes",
stmt.slot,
{"points": stmt.points, "loop_points": stmt.loop_points},
- )
+ stmt,
+ ):
+ return
if self.current_channel is not None:
self._add_event(
DutyEnvelopeEvent(
@@ -376,13 +427,22 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
)
def _analyze_duty_env_use(self, stmt: DutyEnvelopeUseStmt) -> None:
+ if not self._use_defined(stmt, "duty_envelopes", stmt.slot):
+ return
if self.current_channel is not None:
self._add_event(
DutyEnvelopeEvent(tick_position=self.tick_position, slot=stmt.slot)
)
def _analyze_lfo_def(self, stmt: LfoDefStmt) -> None:
- if stmt.transition != 0 or stmt.speed <= 0:
+ if not (
+ self._validate_range(stmt, stmt.delay, 0, 255, "LFO delay")
+ and self._validate_range(stmt, stmt.speed, 1, 255, "LFO period")
+ and self._validate_range(stmt, stmt.depth, 0, 255, "LFO depth")
+ and stmt.transition == 0
+ ):
+ if stmt.transition == 0:
+ return
self.ctx.add_error(
ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
stmt.line,
@@ -392,7 +452,7 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
context=self._context_line(stmt),
)
return
- self._define(
+ if not self._define(
"lfos",
stmt.slot,
{
@@ -401,7 +461,9 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
"depth": stmt.depth,
"transition": stmt.transition,
},
- )
+ stmt,
+ ):
+ return
if self.current_channel is not None:
self._add_event(
LfoEvent(
@@ -416,17 +478,25 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
)
def _analyze_lfo_use(self, stmt: LfoUseStmt) -> None:
+ if not self._use_defined(stmt, "lfos", stmt.slot):
+ return
self._add_event(LfoEvent(tick_position=self.tick_position, slot=stmt.slot))
def _analyze_lfo_off(self, stmt: LfoOffStmt) -> None:
self._add_event(LfoEvent(tick_position=self.tick_position, slot=0, is_off=True))
def _analyze_pitch_env_def(self, stmt: PitchEnvDefStmt) -> None:
- self._define(
+ if not self._validate_values(
+ stmt, stmt.points + stmt.loop_points, -127, 126, "ピッチエンベロープ"
+ ):
+ return
+ if not self._define(
"pitch_envelopes",
stmt.slot,
{"points": stmt.points, "loop_points": stmt.loop_points},
- )
+ stmt,
+ ):
+ return
if self.current_channel is not None:
self._add_event(
PitchEnvEvent(
@@ -439,6 +509,8 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
)
def _analyze_pitch_env_use(self, stmt: PitchEnvUseStmt) -> None:
+ if not self._use_defined(stmt, "pitch_envelopes", stmt.slot):
+ return
self._add_event(PitchEnvEvent(tick_position=self.tick_position, slot=stmt.slot))
def _analyze_pitch_env_off(self, stmt: PitchEnvOffStmt) -> None:
@@ -447,11 +519,17 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
)
def _analyze_note_env_def(self, stmt: NoteEnvDefStmt) -> None:
- self._define(
+ if not self._validate_values(
+ stmt, stmt.points + stmt.loop_points, -127, 126, "ノートエンベロープ"
+ ):
+ return
+ if not self._define(
"note_envelopes",
stmt.slot,
{"points": stmt.points, "loop_points": stmt.loop_points},
- )
+ stmt,
+ ):
+ return
if self.current_channel is not None:
self._add_event(
NoteEnvEvent(
@@ -464,6 +542,8 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
)
def _analyze_note_env_use(self, stmt: NoteEnvUseStmt) -> None:
+ if not self._use_defined(stmt, "note_envelopes", stmt.slot):
+ return
self._add_event(NoteEnvEvent(tick_position=self.tick_position, slot=stmt.slot))
def _analyze_note_env_off(self, stmt: NoteEnvOffStmt) -> None:
@@ -495,28 +575,32 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
if stmt.target is None:
return
if isinstance(stmt.target, int):
- ticks = length_value_to_ticks(stmt.target, 0)
+ ticks = self._ticks_for_value(stmt.target, stmt)
+ if ticks is None:
+ return
self._extend_last_note(ticks)
self.tick_position += ticks
return
if isinstance(stmt.target, NoteStmt):
target_ticks = self._ticks_for(stmt.target)
+ if target_ticks is None:
+ return
self._extend_last_note(target_ticks)
self.tick_position += target_ticks
return
if isinstance(stmt.target, RestStmt):
target_ticks = self._ticks_for(stmt.target)
+ if target_ticks is None:
+ return
self._extend_last_note(target_ticks)
self.tick_position += target_ticks
return
def _analyze_repeat_start(self, stmt: RepeatStartStmt) -> None:
- # Stage 2 feature
- self._analyze_stage2(stmt)
+ super()._analyze_repeat_start(stmt)
def _analyze_repeat_end(self, stmt: RepeatEndStmt) -> None:
- # Stage 2 feature
- self._analyze_stage2(stmt)
+ super()._analyze_repeat_end(stmt)
def _analyze_ext_cmd(self, stmt: ExtCmdStmt) -> None:
# ppmck does not use pyxel ext commands
diff --git a/src/mml_composemusic_mcp/semantic_pyxel.py b/src/mml_composemusic_mcp/semantic_pyxel.py
index 74a6c29..3cb34f6 100644
--- a/src/mml_composemusic_mcp/semantic_pyxel.py
+++ b/src/mml_composemusic_mcp/semantic_pyxel.py
@@ -1,7 +1,5 @@
"""Pyxel semantic analyzer: AST -> NoteSequence IR."""
-import copy
-
from .ast_nodes import (
DutyStmt,
ExtCmdStmt,
@@ -27,13 +25,12 @@ from .ir import (
GlideEvent,
NoteEvent,
NoteSequence,
- RepeatEvent,
RestEvent,
TempoEvent,
VibratoEvent,
VolumeEvent,
)
-from .parser_base import clamp, length_value_to_ticks, note_to_midi
+from .parser_base import clamp, note_to_midi
from .semantic_base import SemanticAnalyzer
PULSE_CHANNELS = {"Pulse1", "Pulse2"}
@@ -43,77 +40,24 @@ PYXEL_DUTY = {
}
-class RepeatFrame:
- """Stack frame for nested repeat expansion."""
-
- def __init__(self, start_tick: int) -> None:
- self.start_tick = start_tick
- self.event_count: int = 0
-
-
class PyxelSemanticAnalyzer(SemanticAnalyzer):
def __init__(self, source: str, program: Program) -> None:
super().__init__(source, program)
self.note_sequence.mode = "pyxel"
self.transpose = 0
self.detune_cents = 0.0
- self.repeat_stack: list[RepeatFrame] = []
-
- def analyze(self) -> tuple[NoteSequence, list[ErrorDetail]]:
- ns, errors = super().analyze()
- references = (
- (EnvelopeEvent, "envelopes"),
- (VibratoEvent, "vibratos"),
- (GlideEvent, "glides"),
- )
- for channel in ns.channels.values():
- for event in channel.events:
- for event_type, table_name in references:
- if (
- isinstance(event, event_type)
- and event.slot != 0
- and event.slot not in ns.definitions[table_name]
- ):
- self.ctx.add_error(
- ErrorCode.SEMANTIC_UNDEFINED_REFERENCE,
- 0,
- 0,
- f"未定義の@{table_name}スロット {event.slot} です。",
- "error",
- "パラメータ付きで定義してから選択してください。",
- )
- return ns, errors
def _reset_state(self, track: Track) -> None:
- self.octave = 4
- self.default_length = 4
+ super()._reset_state(track)
self.velocity = 100
self.gate_time = 0.8
- self.duty = 2
self.transpose = 0
self.detune_cents = 0.0
- self.tick_position = 0
- self.repeat_stack = []
-
- def _analyze_track(self, track: Track) -> None:
- self.current_channel = track.channel
- self._reset_state(track)
- for stmt in track.statements:
- self._analyze_statement(stmt)
- 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="",
- )
def _analyze_note(self, stmt: NoteStmt) -> None:
ticks = self._ticks_for(stmt)
+ if ticks is None:
+ return
midi = note_to_midi(stmt.note_name, self.octave, stmt.accidental)
if self.current_channel != "Noise":
midi += self.transpose
@@ -142,6 +86,8 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer):
def _analyze_rest(self, stmt: RestStmt) -> None:
ticks = self._ticks_for(stmt)
+ if ticks is None:
+ return
event = RestEvent(tick_position=self.tick_position, duration=ticks)
self._add_event(event)
self.tick_position += ticks
@@ -150,10 +96,14 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer):
if stmt.direction == "up":
if self.octave < 7:
self.octave += 1
+ else:
+ self._validate_range(stmt, self.octave + 1, 0, 7, "オクターブ")
return
if stmt.direction == "down":
if self.octave > 0:
self.octave -= 1
+ else:
+ self._validate_range(stmt, self.octave - 1, 0, 7, "オクターブ")
return
if stmt.value is None:
return
@@ -222,6 +172,8 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer):
self.gate_time = stmt.value / 100.0
def _analyze_duty(self, stmt: DutyStmt) -> None:
+ if not self._validate_range(stmt, stmt.value, 0, 3, "音色"):
+ return
if stmt.value in PYXEL_DUTY:
if self.current_channel not in PULSE_CHANNELS:
self.ctx.add_error(
@@ -229,7 +181,7 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer):
line=stmt.line,
column=stmt.column,
message=f"チャンネル '{self.current_channel}' で '@{stmt.value}' は使用できません。",
- severity="warning",
+ severity="error",
hint="トラック番号がチャンネルを決定します。@ コマンドを削除するか、Pulseチャンネル(0: または 1:)に移動してください。",
context=self._context_line(stmt),
)
@@ -239,34 +191,28 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer):
DutyEvent(tick_position=self.tick_position, value=self.duty)
)
return
+ if stmt.value == 0 and self.current_channel == "Triangle":
+ return
+ if stmt.value == 3 and self.current_channel == "Noise":
+ return
if stmt.value == 0:
self.ctx.add_error(
code=ErrorCode.SEMANTIC_CHANNEL_MISMATCH,
line=stmt.line,
column=stmt.column,
message=f"チャンネル '{self.current_channel}' で '@0' (Triangle) は使用できません。",
- severity="warning",
+ severity="error",
hint="トラック番号がチャンネルを決定します。@ コマンドを削除するか、適切なチャンネルに移動してください。",
context=self._context_line(stmt),
)
- elif stmt.value == 3:
+ else:
self.ctx.add_error(
code=ErrorCode.SEMANTIC_CHANNEL_MISMATCH,
line=stmt.line,
column=stmt.column,
message=f"チャンネル '{self.current_channel}' で '@3' (Noise) は使用できません。",
- severity="warning",
- hint="トラック番号がチャンネルを決定します。@ コマンドを削除するか、適切なチャンネルに移動してください。",
- context=self._context_line(stmt),
- )
- else:
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_INVALID_NUMBER,
- line=stmt.line,
- column=stmt.column,
- message=f"'@' の後に無効な値 '{stmt.value}' が見つかりました。",
severity="error",
- hint="例: @1 のように指定してください。",
+ hint="トラック番号がチャンネルを決定します。@ コマンドを削除するか、適切なチャンネルに移動してください。",
context=self._context_line(stmt),
)
@@ -287,95 +233,58 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer):
self._add_event(TempoEvent(tick_position=self.tick_position, bpm=stmt.value))
def _analyze_transpose(self, stmt: TransposeStmt) -> None:
+ if not self._validate_range(stmt, stmt.value, -127, 127, "トランスポーズ"):
+ return
self.transpose = stmt.value
def _analyze_detune(self, stmt) -> None: # type: ignore[no-untyped-def]
+ if not self._validate_range(stmt, stmt.value, -127, 127, "ディチューン"):
+ return
self.detune_cents = float(stmt.value)
def _analyze_tie(self, stmt: TieStmt) -> None:
if stmt.target is None:
return
if isinstance(stmt.target, int):
- ticks = length_value_to_ticks(stmt.target, 0)
+ ticks = self._ticks_for_value(stmt.target, stmt)
+ if ticks is None:
+ return
self._extend_last_note(ticks)
self.tick_position += ticks
return
if isinstance(stmt.target, NoteStmt):
target_ticks = self._ticks_for(stmt.target)
+ if target_ticks is None:
+ return
self._extend_last_note(target_ticks)
self.tick_position += target_ticks
return
if isinstance(stmt.target, RestStmt):
target_ticks = self._ticks_for(stmt.target)
+ if target_ticks is None:
+ return
self._extend_last_note(target_ticks)
self.tick_position += target_ticks
return
def _analyze_repeat_start(self, stmt: RepeatStartStmt) -> None:
- 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)
+ super()._analyze_repeat_start(stmt)
def _analyze_repeat_end(self, stmt: RepeatEndStmt) -> None:
- if not self.repeat_stack:
- self.ctx.add_error(
- code=ErrorCode.SYNTAX_UNMATCHED_REPEAT_END,
- line=stmt.line,
- column=stmt.column,
- message="']' に対応する '[' が見つかりません。",
- severity="error",
- hint="直前の [ を確認するか、余分な ] を削除してください。",
- context=self._context_line(stmt),
- )
- return
- frame = self.repeat_stack.pop()
- start_tick = frame.start_tick
- ch = self.note_sequence.channels[self.current_channel] # type: ignore[index]
-
- count = stmt.count
- if count is None:
- count = 0 # infinite
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message="無限リピートはWAV生成時に2回で打ち切られます。",
- severity="warning",
- hint="回数を指定するか、2回の再生で十分であることを確認してください。",
- context=self._context_line(stmt),
- )
-
- 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,
- )
- )
-
- def _clone_event(self, event, tick_position: int): # type: ignore[no-untyped-def]
- new = copy.copy(event)
- new.tick_position = tick_position
- return new
+ super()._analyze_repeat_end(stmt)
def _analyze_ext_cmd(self, stmt: ExtCmdStmt) -> None:
if stmt.slot == 0:
+ if stmt.params:
+ self.ctx.add_error(
+ ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ stmt.line,
+ stmt.column,
+ f"@{stmt.cmd}0 は解除専用で、パラメータを指定できません。",
+ "error",
+ context=self._context_line(stmt),
+ )
+ return
if stmt.cmd == "ENV":
ev = EnvelopeEvent(tick_position=self.tick_position, slot=0)
elif stmt.cmd == "VIB":
@@ -402,7 +311,7 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer):
)
return
if stmt.cmd == "ENV":
- if len(stmt.params) % 2:
+ if len(stmt.params) < 2 or len(stmt.params) % 2:
self.ctx.add_error(
ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
stmt.line,
@@ -430,14 +339,21 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer):
"points": points
}
elif stmt.cmd == "VIB":
- if len(stmt.params) >= 3:
- params = {
- "delay_ticks": stmt.params[0],
- "period_ticks": stmt.params[1],
- "depth_cents": stmt.params[2],
- }
- else:
- params = {}
+ if len(stmt.params) != 3:
+ self.ctx.add_error(
+ ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ stmt.line,
+ stmt.column,
+ "@VIBのパラメータは3値で指定してください。",
+ "error",
+ context=self._context_line(stmt),
+ )
+ return
+ params = {
+ "delay_ticks": stmt.params[0],
+ "period_ticks": stmt.params[1],
+ "depth_cents": stmt.params[2],
+ }
ev = VibratoEvent(
tick_position=self.tick_position,
slot=stmt.slot,
@@ -445,13 +361,20 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer):
)
self.note_sequence.definitions["vibratos"][stmt.slot] = params
else:
- if len(stmt.params) >= 2:
- params = {
- "initial_offset_cents": stmt.params[0],
- "duration_ticks": stmt.params[1],
- }
- else:
- params = {}
+ if len(stmt.params) != 2:
+ self.ctx.add_error(
+ ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ stmt.line,
+ stmt.column,
+ "@GLIのパラメータは2値で指定してください。",
+ "error",
+ context=self._context_line(stmt),
+ )
+ return
+ params = {
+ "initial_offset_cents": stmt.params[0],
+ "duration_ticks": stmt.params[1],
+ }
ev = GlideEvent(
tick_position=self.tick_position,
slot=stmt.slot,
@@ -460,6 +383,22 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer):
self.note_sequence.definitions["glides"][stmt.slot] = params
self._add_event(ev)
else:
+ table_name = {
+ "ENV": "envelopes",
+ "VIB": "vibratos",
+ "GLI": "glides",
+ }[stmt.cmd]
+ if stmt.slot not in self.note_sequence.definitions[table_name]:
+ self.ctx.add_error(
+ ErrorCode.SEMANTIC_UNDEFINED_REFERENCE,
+ stmt.line,
+ stmt.column,
+ f"未定義の@{stmt.cmd}スロット {stmt.slot} が参照されました。",
+ "error",
+ "先に同じスロットをパラメータ付きで定義してください。",
+ self._context_line(stmt),
+ )
+ return
if stmt.cmd == "ENV":
ev = EnvelopeEvent(tick_position=self.tick_position, slot=stmt.slot)
elif stmt.cmd == "VIB":