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