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/ast_nodes.py | 8 + src/mml_composemusic_mcp/lexer.py | 46 +++--- src/mml_composemusic_mcp/parser_base.py | 5 + src/mml_composemusic_mcp/parser_ppmck.py | 230 +++++++++++++++++++++------ src/mml_composemusic_mcp/parser_pyxel.py | 38 ++++- src/mml_composemusic_mcp/semantic_base.py | 190 ++++++++++++++++++++++- src/mml_composemusic_mcp/semantic_ppmck.py | 214 ++++++++++++++++++-------- src/mml_composemusic_mcp/semantic_pyxel.py | 239 +++++++++++------------------ 8 files changed, 676 insertions(+), 294 deletions(-) (limited to 'src') 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 @@ -160,6 +160,13 @@ class RepeatStartStmt(ASTNode): pass +@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,", 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": -- cgit v1.2.3