summaryrefslogtreecommitdiff
path: root/src/mml_composemusic_mcp/semantic_base.py
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/mml_composemusic_mcp/semantic_base.py
parent9cbb60cf68ab2a8bd9ccc0dca4fbd46f2ae9b928 (diff)
feat: MMLパーサーと意味解析を厳格化
- ppmckの終端分岐付きリピートとL制御トラックを実装 - case、構文構造、音長、エフェクト値域の検証を強化 - Pyxel拡張コマンドのarityと参照検証を追加 - リピート展開上限と構文・意味エラー時の合成停止を追加 - BNF、設計書、適合監査、回帰テストを更新
Diffstat (limited to 'src/mml_composemusic_mcp/semantic_base.py')
-rw-r--r--src/mml_composemusic_mcp/semantic_base.py190
1 files changed, 186 insertions, 4 deletions
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