diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-07-15 20:00:24 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-07-15 20:00:24 +0900 |
| commit | 7249ca3a6fdf51f6b7ea66ac6854edd5e02ae5ec (patch) | |
| tree | 37a42621228a6f7a87ef02ae08d4613b2151a1a5 /src/mml_composemusic_mcp/semantic_ppmck.py | |
| parent | 9cbb60cf68ab2a8bd9ccc0dca4fbd46f2ae9b928 (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.py | 214 |
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 |
