summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-07-11 19:57:02 +0900
committertanaka takashi <isazaka@gmail.com>2026-07-11 19:57:02 +0900
commite16782c208320138d77b88f2ff359e3e9e059e76 (patch)
tree5e19fde4fa841b9b3f2ddef9f5efdb342ba6e911
parentc2e5098d030fe902eff258e10d2fefc01226e4bf (diff)
fix: レビュー指摘事項の修正(エラー戻り値統一、タイ延長、ネストリピート、専用エラーコード)
High severity: - compose action エラー戻り値の validation キー統一(server.py) - ppmck/pyxel タイ & の duration 延長修正(_tie_pending フラグ導入) - pyxel ネストリピート対応(RepeatFrame + イベントスナップショット方式) Medium severity: - 専用エラーコード追加: VALIDATION_MISSING_PARAMETER, VALIDATION_INVALID_MODE, VALIDATION_INVALID_ACTION - SYSTEM_INTERNAL_ERROR キャッチ範囲拡大(compose/validate全体をtry/except) Low severity: - 設計書 line/column を1始まりに明記、APIレベルエラーコード追記 テスト: 129 passed, ruff: All checks passed
-rw-r--r--doc/Design_err.md19
-rw-r--r--src/mml_composemusic_mcp/ir.py5
-rw-r--r--src/mml_composemusic_mcp/parser_ppmck.py21
-rw-r--r--src/mml_composemusic_mcp/parser_pyxel.py59
-rw-r--r--src/mml_composemusic_mcp/server.py170
-rw-r--r--tests/test_compose_mml_tool.py8
-rw-r--r--tests/test_error_codes.py41
-rw-r--r--tests/test_parser_detailed.py37
8 files changed, 261 insertions, 99 deletions
diff --git a/doc/Design_err.md b/doc/Design_err.md
index 3718023..d74f3a8 100644
--- a/doc/Design_err.md
+++ b/doc/Design_err.md
@@ -7,8 +7,8 @@ ErrorDetail に `code`(機械処理用のエラーコード)を追加してã
```
{
code: string, # エラーコード(機械処理用、LLMがパターン認識可能)
- line: int, # 行番号
- column: int, # 列番号
+ line: int, # 行番号(1始まり)
+ column: int, # 列番号(1始まり)
message: string, # エラーメッセージ(プレースホルダ展開済み)
severity: "error" | "warning",
hint: string, # LLM向けの修正ヒント
@@ -17,6 +17,7 @@ ErrorDetail に `code`(機械処理用のエラーコード)を追加してã
```
> `code` を追加しました。LLMは `code` を見てエラーパターンを分類し、`hint` に従って修正できます。`context` は該当行のテキスト前後を切り出したもので、LLMが位置を特定しやすくなります。
+> **注意**: `line` と `column` は **1始まり** です(最初の行・最初の列が1)。システムエラーやAPIレベルエラーなどで位置情報がない場合は `0` を使用します。
---
@@ -78,6 +79,18 @@ LLMの修正対象外。内部処理の失敗。`hint` は空または「再試è
---
+### APIレベルエラー(VALIDATION_*)
+
+MML構文ではなく、APIパラメータのバリデーションエラー。`line` / `column` は `0` となります。
+
+| code | trigger | message テンプレート | hint | severity |
+|---|---|---|---|---|
+| `VALIDATION_MISSING_PARAMETER` | compose/validateで必須パラメータ(mml, mode)が欠落 | `mml と mode は compose/validate の必須パラメータです。` | `mml に MML 文字列、mode に 'ppmck' または 'pyxel' を指定してください。` | error |
+| `VALIDATION_INVALID_MODE` | 未知のmodeが指定された | `未知のモード '{mode}' です。` | `mode は 'ppmck' または 'pyxel' を指定してください。` | error |
+| `VALIDATION_INVALID_ACTION` | 未知のactionが指定された | `未知の action '{action}' です。` | `action は 'compose', 'validate', 'template' のいずれかを指定してください。` | error |
+
+---
+
### エラーレスポンスの実例
#### 構文エラーの例(LLMが修正可能)
@@ -147,4 +160,4 @@ compose action で ERROR がある場合:
---
-これで、エラーまわりの仕様を規定しました。構文エラーは14種類、システムエラーは3種類。LLMが `code` を見てパターン認識し、`hint` に従って修正できる構造になっています。
+これで、エラーまわりの仕様を規定しました。構文エラーは14種類、システムエラーは3種類、APIレベルエラーは3種類。LLMが `code` を見てパターン認識し、`hint` に従って修正できる構造になっています。
diff --git a/src/mml_composemusic_mcp/ir.py b/src/mml_composemusic_mcp/ir.py
index 87385df..42adc13 100644
--- a/src/mml_composemusic_mcp/ir.py
+++ b/src/mml_composemusic_mcp/ir.py
@@ -27,6 +27,11 @@ class ErrorCode(Enum):
SYSTEM_WAV_WRITE_FAILED = "SYSTEM_WAV_WRITE_FAILED"
SYSTEM_INTERNAL_ERROR = "SYSTEM_INTERNAL_ERROR"
+ # API-level errors (parameter validation, not MML syntax)
+ VALIDATION_MISSING_PARAMETER = "VALIDATION_MISSING_PARAMETER"
+ VALIDATION_INVALID_MODE = "VALIDATION_INVALID_MODE"
+ VALIDATION_INVALID_ACTION = "VALIDATION_INVALID_ACTION"
+
@dataclass
class ErrorDetail:
diff --git a/src/mml_composemusic_mcp/parser_ppmck.py b/src/mml_composemusic_mcp/parser_ppmck.py
index 34a7dd2..db91950 100644
--- a/src/mml_composemusic_mcp/parser_ppmck.py
+++ b/src/mml_composemusic_mcp/parser_ppmck.py
@@ -41,6 +41,7 @@ class PpmckParser:
self.headers: list[tuple[str, str]] = []
self.loop_start: int | None = None
self.tracks_seen: set[str] = set()
+ self._tie_pending: bool = False
def _context_line(self, token: Token) -> str:
lines = self.source.splitlines()
@@ -202,6 +203,13 @@ class PpmckParser:
else:
break
+ if self._tie_pending:
+ # Tie: extend the last note/rest's duration instead of creating a new event
+ self._extend_last_note(ticks)
+ self.tick_position += ticks
+ self._tie_pending = False
+ return
+
if self.current_channel == "Noise":
self.ctx.add_error(
code=ErrorCode.SYNTAX_CHANNEL_MISMATCH,
@@ -242,6 +250,11 @@ class PpmckParser:
self.ctx.advance()
length, dots = self._read_length()
ticks = length_value_to_ticks(length, dots)
+ if self._tie_pending:
+ self._extend_last_note(ticks)
+ self.tick_position += ticks
+ self._tie_pending = False
+ return
event = RestEvent(tick_position=self.tick_position, duration=ticks)
self._add_event(event)
self.tick_position += ticks
@@ -372,11 +385,11 @@ class PpmckParser:
self._extend_last_note(ticks)
self.tick_position += ticks
return
- # For note/rest, parser will create next event; we just note that previous
- # event should be merged. Simple approach: reduce tick_position and mark.
- # Here we just allow the next note to overwrite position and merge later.
- # Implementation: back up tick position so next note duration adds to previous.
+ # For note/rest: back up tick_position so the next event starts at the
+ # end of the last note/rest. The next note's duration will be added
+ # to the previous note's duration via _tie_pending flag.
self.tick_position = self._last_note_end()
+ self._tie_pending = True
def _last_note_end(self) -> int:
ch = self.note_sequence.channels[self.current_channel] # type: ignore[index]
diff --git a/src/mml_composemusic_mcp/parser_pyxel.py b/src/mml_composemusic_mcp/parser_pyxel.py
index 3bacb5b..336a55e 100644
--- a/src/mml_composemusic_mcp/parser_pyxel.py
+++ b/src/mml_composemusic_mcp/parser_pyxel.py
@@ -36,6 +36,15 @@ PYXEL_DUTY = {
}
+class RepeatFrame:
+ """Stack frame for nested repeat expansion."""
+
+ def __init__(self, start_tick: int) -> None:
+ self.start_tick = start_tick
+ # Snapshot of event indices that existed before the repeat section
+ self.event_count: int = 0
+
+
class PyxelParser:
def __init__(self, source: str, tokens: list[Token]) -> None:
self.source = source
@@ -51,7 +60,8 @@ class PyxelParser:
self.detune_cents = 0
self.tick_position = 0
self.tracks_seen: set[str] = set()
- self.repeat_stack: list[int] = []
+ self.repeat_stack: list[RepeatFrame] = []
+ self._tie_pending: bool = False
def _context_line(self, token: Token) -> str:
lines = self.source.splitlines()
@@ -71,7 +81,7 @@ class PyxelParser:
self._parse_statement()
if self.repeat_stack:
- for _start_tick in self.repeat_stack:
+ for _frame in self.repeat_stack:
self.ctx.add_error(
code=ErrorCode.SYNTAX_UNTERMINATED_REPEAT,
line=1,
@@ -189,6 +199,13 @@ class PyxelParser:
else:
break
+ if self._tie_pending:
+ # Tie: extend the last note/rest's duration instead of creating a new event
+ self._extend_last_note(ticks)
+ self.tick_position += ticks
+ self._tie_pending = False
+ return
+
if self.current_channel == "Noise":
midi = note_to_midi(note, self.octave, accidental)
else:
@@ -221,6 +238,11 @@ class PyxelParser:
self.ctx.advance()
length, dots = self._read_length()
ticks = length_value_to_ticks(length, dots)
+ if self._tie_pending:
+ self._extend_last_note(ticks)
+ self.tick_position += ticks
+ self._tie_pending = False
+ return
event = RestEvent(tick_position=self.tick_position, duration=ticks)
self._add_event(event)
self.tick_position += ticks
@@ -404,7 +426,11 @@ class PyxelParser:
self._extend_last_note(ticks)
self.tick_position += ticks
return
+ # For note/rest: back up tick_position so the next event starts at the
+ # end of the last note/rest. The next note's duration will be added
+ # to the previous note's duration via _tie_pending flag.
self.tick_position = self._last_note_end()
+ self._tie_pending = True
def _last_note_end(self) -> int:
ch = self.note_sequence.channels[self.current_channel] # type: ignore[index]
@@ -422,7 +448,10 @@ class PyxelParser:
def _parse_repeat_start(self) -> None:
self.ctx.advance()
- self.repeat_stack.append(self.tick_position)
+ 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)
def _parse_repeat_end(self) -> None:
token = self.ctx.advance()
@@ -437,7 +466,10 @@ class PyxelParser:
context=self._context_line(token),
)
return
- start_tick = self.repeat_stack.pop()
+ frame = self.repeat_stack.pop()
+ start_tick = frame.start_tick
+ ch = self.note_sequence.channels[self.current_channel] # type: ignore[index]
+
count_str = token.value
if count_str == "":
count = 0 # infinite
@@ -452,24 +484,23 @@ class PyxelParser:
)
else:
count = int(count_str)
- segment = [
- e
- for e in self.note_sequence.channels[self.current_channel].events # type: ignore[index]
- if start_tick <= e.tick_position < self.tick_position
- ]
+
+ # Collect events that belong to this repeat section (created after the frame snapshot)
+ 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 * (self.tick_position - start_tick)
- for ev in segment:
+ 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
- self.note_sequence.channels[self.current_channel].events.sort( # type: ignore[index]
- key=lambda e: e.tick_position
- )
+
+ ch.events.sort(key=lambda e: e.tick_position)
self._add_event(
RepeatEvent(
start_tick=start_tick,
diff --git a/src/mml_composemusic_mcp/server.py b/src/mml_composemusic_mcp/server.py
index 90d4dab..3061e6d 100644
--- a/src/mml_composemusic_mcp/server.py
+++ b/src/mml_composemusic_mcp/server.py
@@ -35,7 +35,7 @@ def _parse_mml(mml: str, mode: str) -> tuple[dict, list[ErrorDetail]]:
else:
errors = [
ErrorDetail(
- code=ErrorCode.SYNTAX_INVALID_TOKEN,
+ code=ErrorCode.VALIDATION_INVALID_MODE,
line=0,
column=0,
message=f"未知のモード '{mode}' です。",
@@ -70,100 +70,126 @@ def compose_mml(
return {
"success": False,
"valid": False,
- "errors": [
- {
- "code": ErrorCode.SYNTAX_INVALID_TOKEN.value,
- "line": 0,
- "column": 0,
- "message": "mml と mode は compose/validate の必須パラメータです。",
- "severity": "error",
- "hint": "mml に MML 文字列、mode に 'ppmck' または 'pyxel' を指定してください。",
- }
- ],
- "warnings": [],
+ "note_sequence": None,
+ "validation": {
+ "errors": [
+ {
+ "code": ErrorCode.VALIDATION_MISSING_PARAMETER.value,
+ "line": 0,
+ "column": 0,
+ "message": "mml と mode は compose/validate の必須パラメータです。",
+ "severity": "error",
+ "hint": "mml に MML 文字列、mode に 'ppmck' または 'pyxel' を指定してください。",
+ }
+ ],
+ "warnings": [],
+ },
}
- note_sequence_dict, errors = _parse_mml(mml, mode)
- errors_list, warnings_list = _split_errors(errors)
- if action == "validate":
+ try:
+ note_sequence_dict, errors = _parse_mml(mml, mode)
+ errors_list, warnings_list = _split_errors(errors)
+ if action == "validate":
+ return {
+ "valid": not _is_error(errors),
+ "errors": errors_list,
+ "warnings": warnings_list,
+ "note_sequence": note_sequence_dict,
+ "channel_summary": build_channel_summary(note_sequence_dict)
+ if note_sequence_dict
+ else [],
+ }
+ # compose
+ if _is_error(errors):
+ return {
+ "success": False,
+ "wav_path": None,
+ "duration_sec": 0,
+ "note_sequence": note_sequence_dict,
+ "validation": {"errors": errors_list, "warnings": warnings_list},
+ }
+
+ ns = _dict_to_note_sequence(note_sequence_dict)
+ try:
+ wave_data, duration, synth_errors = synthesize(
+ ns, mode, sample_rate, normalize
+ )
+ except Exception as exc:
+ return {
+ "success": False,
+ "wav_path": None,
+ "duration_sec": 0,
+ "note_sequence": note_sequence_dict,
+ "validation": {
+ "errors": [
+ {
+ "code": ErrorCode.SYSTEM_SYNTHESIS_FAILED.value,
+ "line": 0,
+ "column": 0,
+ "message": f"音声合成中にエラーが発生しました: {exc}",
+ "severity": "error",
+ "hint": "MMLの内容を確認の上、再度お試しください。問題が続く場合は、短いMMLから試してください。",
+ }
+ ],
+ "warnings": warnings_list,
+ },
+ }
+ errors.extend(synth_errors)
+ errors_list, warnings_list = _split_errors(errors)
+ if _is_error(errors):
+ return {
+ "success": False,
+ "wav_path": None,
+ "duration_sec": 0,
+ "note_sequence": note_sequence_dict,
+ "validation": {"errors": errors_list, "warnings": warnings_list},
+ }
+ wav_path = OUTPUT_DIR / "output.wav"
+ wav_errors = write_wav(wav_path, wave_data, sample_rate)
+ errors.extend(wav_errors)
+ errors_list, warnings_list = _split_errors(errors)
return {
- "valid": not _is_error(errors),
- "errors": errors_list,
- "warnings": warnings_list,
- "note_sequence": note_sequence_dict,
- "channel_summary": build_channel_summary(note_sequence_dict)
- if note_sequence_dict
- else [],
- }
- # compose
- if _is_error(errors):
- return {
- "success": False,
- "wav_path": None,
- "duration_sec": 0,
+ "success": not _is_error(errors),
+ "wav_path": str(wav_path) if not _is_error(errors) else None,
+ "duration_sec": duration,
"note_sequence": note_sequence_dict,
"validation": {"errors": errors_list, "warnings": warnings_list},
}
-
- ns = _dict_to_note_sequence(note_sequence_dict)
- try:
- wave_data, duration, synth_errors = synthesize(
- ns, mode, sample_rate, normalize
- )
except Exception as exc:
return {
"success": False,
"wav_path": None,
"duration_sec": 0,
- "note_sequence": note_sequence_dict,
+ "note_sequence": None,
"validation": {
"errors": [
{
- "code": ErrorCode.SYSTEM_SYNTHESIS_FAILED.value,
+ "code": ErrorCode.SYSTEM_INTERNAL_ERROR.value,
"line": 0,
"column": 0,
- "message": f"音声合成中にエラーが発生しました: {exc}",
+ "message": f"内部エラーが発生しました: {exc}",
"severity": "error",
- "hint": "MMLの内容を確認の上、再度お試しください。問題が続く場合は、短いMMLから試してください。",
+ "hint": "MMLの内容を確認の上、再度お試しください。",
}
],
- "warnings": warnings_list,
+ "warnings": [],
},
}
- errors.extend(synth_errors)
- errors_list, warnings_list = _split_errors(errors)
- if _is_error(errors):
- return {
- "success": False,
- "wav_path": None,
- "duration_sec": 0,
- "note_sequence": note_sequence_dict,
- "validation": {"errors": errors_list, "warnings": warnings_list},
- }
- wav_path = OUTPUT_DIR / "output.wav"
- wav_errors = write_wav(wav_path, wave_data, sample_rate)
- errors.extend(wav_errors)
- errors_list, warnings_list = _split_errors(errors)
- return {
- "success": not _is_error(errors),
- "wav_path": str(wav_path) if not _is_error(errors) else None,
- "duration_sec": duration,
- "note_sequence": note_sequence_dict,
- "validation": {"errors": errors_list, "warnings": warnings_list},
- }
return {
"success": False,
- "errors": [
- {
- "code": ErrorCode.SYNTAX_INVALID_TOKEN.value,
- "line": 0,
- "column": 0,
- "message": f"未知の action '{action}' です。",
- "severity": "error",
- "hint": "action は 'compose', 'validate', 'template' のいずれかを指定してください。",
- }
- ],
- "warnings": [],
+ "validation": {
+ "errors": [
+ {
+ "code": ErrorCode.VALIDATION_INVALID_ACTION.value,
+ "line": 0,
+ "column": 0,
+ "message": f"未知の action '{action}' です。",
+ "severity": "error",
+ "hint": "action は 'compose', 'validate', 'template' のいずれかを指定してください。",
+ }
+ ],
+ "warnings": [],
+ },
}
diff --git a/tests/test_compose_mml_tool.py b/tests/test_compose_mml_tool.py
index af1e3e2..eb1b093 100644
--- a/tests/test_compose_mml_tool.py
+++ b/tests/test_compose_mml_tool.py
@@ -85,13 +85,13 @@ def test_template_invalid_fallback(compose_mml):
def test_compose_missing_mml(compose_mml):
result = compose_mml(action="compose", mml="", mode="ppmck")
assert result["success"] is False
- assert any(e["severity"] == "error" for e in result["errors"])
+ assert any(e["severity"] == "error" for e in result["validation"]["errors"])
def test_compose_missing_mode(compose_mml):
result = compose_mml(action="compose", mml="A c", mode="")
assert result["success"] is False
- assert any(e["severity"] == "error" for e in result["errors"])
+ assert any(e["severity"] == "error" for e in result["validation"]["errors"])
def test_validate_missing_mode(compose_mml):
@@ -103,7 +103,7 @@ def test_compose_unknown_mode(compose_mml):
result = compose_mml(action="compose", mml="A c", mode="xyz")
assert result["success"] is False
assert any(
- e["code"] == "SYNTAX_INVALID_TOKEN" for e in result["validation"]["errors"]
+ e["code"] == "VALIDATION_INVALID_MODE" for e in result["validation"]["errors"]
)
@@ -127,7 +127,7 @@ def test_compose_with_warning(compose_mml, tmp_output_dir):
def test_action_invalid(compose_mml):
result = compose_mml(action="unknown")
assert result["success"] is False
- assert any("action" in e["message"] for e in result["errors"])
+ assert any("action" in e["message"] for e in result["validation"]["errors"])
def test_compose_sample_rates(compose_mml, tmp_output_dir):
diff --git a/tests/test_error_codes.py b/tests/test_error_codes.py
index 19b4575..496ce84 100644
--- a/tests/test_error_codes.py
+++ b/tests/test_error_codes.py
@@ -17,14 +17,16 @@ def _find_code(result, code, key="errors"):
def _has_error(result):
- return any(e["severity"] == "error" for e in result["validation"]["errors"])
+ if "validation" in result:
+ return any(e["severity"] == "error" for e in result["validation"]["errors"])
+ return any(e["severity"] == "error" for e in result["errors"])
# --- SYNTAX_INVALID_TOKEN ---
def test_syntax_invalid_token_mode(compose_mml):
result = compose_mml(action="compose", mml="A c", mode="xyz")
assert result["success"] is False
- assert _find_code(result, ErrorCode.SYNTAX_INVALID_TOKEN.value)
+ assert _find_code(result, ErrorCode.VALIDATION_INVALID_MODE.value)
# --- SYNTAX_INVALID_NUMBER ---
@@ -201,6 +203,27 @@ def test_syntax_unterminated_header(compose_mml):
assert _find_code(result, ErrorCode.SYNTAX_UNTERMINATED_HEADER.value)
+# --- VALIDATION_MISSING_PARAMETER ---
+def test_validation_missing_parameter(compose_mml):
+ result = compose_mml(action="compose", mml="", mode="ppmck")
+ assert result["success"] is False
+ assert _find_code(result, ErrorCode.VALIDATION_MISSING_PARAMETER.value)
+
+
+# --- VALIDATION_INVALID_MODE ---
+def test_validation_invalid_mode(compose_mml):
+ result = compose_mml(action="compose", mml="A c", mode="xyz")
+ assert result["success"] is False
+ assert _find_code(result, ErrorCode.VALIDATION_INVALID_MODE.value)
+
+
+# --- VALIDATION_INVALID_ACTION ---
+def test_validation_invalid_action(compose_mml):
+ result = compose_mml(action="unknown")
+ assert result["success"] is False
+ assert _find_code(result, ErrorCode.VALIDATION_INVALID_ACTION.value)
+
+
# --- SYSTEM_SYNTHESIS_FAILED ---
def test_system_synthesis_failed(compose_mml):
with patch(
@@ -234,6 +257,20 @@ def test_system_wav_write_failed(tmp_path):
# --- SYSTEM_INTERNAL_ERROR ---
+def test_system_internal_error(compose_mml):
+ with patch(
+ "mml_composemusic_mcp.server._parse_mml",
+ side_effect=RuntimeError("unexpected internal error"),
+ ):
+ result = compose_mml(
+ action="compose",
+ mml="A t120 l4 o4\n c",
+ mode="ppmck",
+ )
+ assert result["success"] is False
+ assert _find_code(result, ErrorCode.SYSTEM_INTERNAL_ERROR.value)
+
+
def test_system_internal_error_not_exposed(compose_mml):
result = compose_mml(action="compose", mml="A t120 l4 o4\n c", mode="ppmck")
assert isinstance(result, dict)
diff --git a/tests/test_parser_detailed.py b/tests/test_parser_detailed.py
index 88d7095..3752562 100644
--- a/tests/test_parser_detailed.py
+++ b/tests/test_parser_detailed.py
@@ -143,6 +143,28 @@ def test_pyxel_repeat_count3():
assert len(notes) == 6
+def test_pyxel_nested_repeat():
+ source = "0: T120 L4 O4\n[ [ C D ]2 E ]2"
+ tokens = tokenize(source, "pyxel")
+ ns, errors = parse_pyxel(source, tokens)
+ assert not any(e.severity == "error" for e in errors)
+ notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
+ # Inner: [C D]2 -> C D C D (4 notes)
+ # Outer: [ ... E ]2 -> (C D C D E) * 2 = 10 notes
+ assert len(notes) == 10
+
+
+def test_pyxel_nested_repeat_count3():
+ source = "0: T120 L4 O4\n[ [ C D ]2 E ]3"
+ tokens = tokenize(source, "pyxel")
+ ns, errors = parse_pyxel(source, tokens)
+ assert not any(e.severity == "error" for e in errors)
+ notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
+ # Inner: [C D]2 -> C D C D (4 notes)
+ # Outer: [ ... E ]3 -> (C D C D E) * 3 = 15 notes
+ assert len(notes) == 15
+
+
# --- Tie ---
@@ -152,6 +174,21 @@ def test_ppmck_tie_note():
ns, errors = parse_ppmck(source, tokens)
# Tie should merge notes; check no error
assert not any(e.severity == "error" for e in errors)
+ notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
+ # c4 & c4 should produce a single note with doubled duration (192*2=384)
+ assert len(notes) == 1
+ assert notes[0].duration == 384
+
+
+def test_pyxel_tie_note():
+ source = "0: T120 L4 O4\n C4 & C4"
+ tokens = tokenize(source, "pyxel")
+ ns, errors = parse_pyxel(source, tokens)
+ assert not any(e.severity == "error" for e in errors)
+ notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)]
+ # C4 & C4 should produce a single note with doubled duration (192*2=384)
+ assert len(notes) == 1
+ assert notes[0].duration == 384
def test_pyxel_tie_length():