From 7249ca3a6fdf51f6b7ea66ac6854edd5e02ae5ec Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Wed, 15 Jul 2026 20:00:24 +0900 Subject: feat: MMLパーサーと意味解析を厳格化 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ppmckの終端分岐付きリピートとL制御トラックを実装 - case、構文構造、音長、エフェクト値域の検証を強化 - Pyxel拡張コマンドのarityと参照検証を追加 - リピート展開上限と構文・意味エラー時の合成停止を追加 - BNF、設計書、適合監査、回帰テストを更新 --- src/mml_composemusic_mcp/semantic_base.py | 190 +++++++++++++++++++++++++++++- 1 file changed, 186 insertions(+), 4 deletions(-) (limited to 'src/mml_composemusic_mcp/semantic_base.py') 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 -- cgit v1.2.3