summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md13
-rw-r--r--doc/Design.md6
-rw-r--r--doc/mcp.md2
-rw-r--r--pyproject.toml2
-rw-r--r--samples/README.md11
-rw-r--r--samples/ppmck/output.mml86
-rw-r--r--samples/ppmck/output.wavbin0 -> 4312044 bytes
-rw-r--r--samples/pyxel/output.mml79
-rw-r--r--samples/pyxel/output.wavbin0 -> 4312044 bytes
-rw-r--r--src/mml_composemusic_mcp/ir.py1
-rw-r--r--src/mml_composemusic_mcp/server.py43
-rw-r--r--tests/test_compose_mml_tool.py27
12 files changed, 242 insertions, 28 deletions
diff --git a/README.md b/README.md
index 7733139..d7c9049 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,17 @@ Pulse 2ch、Triangle 1ch、Noise 1chの構成を持つ簡易2A03 APU風シンセ
- LLMがコピーして改変できる8種類の作例テンプレートを収録
- stdio、HTTP、SSE、Streamable HTTPトランスポートに対応
+## サンプルデータ
+
+実際にLLMを使用してこのMCPツールで作曲させてみたサンプルデータです。
+ppmck版
+- [音声](samples/ppmck/output.wav)
+- [mml](samples/ppmck/output.mml)
+
+pyxel版
+- [音声](samples/pyxel/output.wav)
+- [mml](samples/pyxel/output.mml)
+
## 必要環境
- Python 3.14以上
@@ -147,7 +158,7 @@ MCPクライアントでは、たとえば次のように依頼できます。
}
```
-成功時は`success: true`、`wav_path`、`duration_sec`、解析済みの`note_sequence`を返します。ファイル名は`output_YYYYMMDD_HHMMSS_mmm.wav`形式で、指定した出力ディレクトリへ保存されます。
+成功時は`success: true`、`wav_path`、`duration_sec`、解析済みの`note_sequence`を返します。指定した出力ディレクトリの下に生成時刻を表す`YYYYMMDD_HHMMSS_mmm`ディレクトリを作り、`output.wav`と、合成に使用したMML原文の`output.mml`を保存します。
`wav_path`はMCPサーバが動作しているマシン上のローカルパスです。リモートのHTTPサーバとして運用する場合、このツール自体はWAVのダウンロード配信を行いません。
diff --git a/doc/Design.md b/doc/Design.md
index 92f8dbc..f345d82 100644
--- a/doc/Design.md
+++ b/doc/Design.md
@@ -140,7 +140,7 @@ MML文字列
```json
{
"success": true,
- "wav_path": "./data/output_20260711_120000_123.wav",
+ "wav_path": "./data/20260711_120000_123/output.wav",
"duration_sec": 2.0,
"note_sequence": { ... },
"validation": {
@@ -161,7 +161,7 @@ MML文字列
> `compose` は必ず検証を内包する。エラーがあればWAVは生成せず、検証結果のみ返す。
>
-> **WAVファイル名**: 固定名 `output.wav` ではなく、生成時刻を含む `output_YYYYMMDD_HHMMSS_mmm.wav` 形式で出力される。
+> **出力構成**: 生成時刻を表す `YYYYMMDD_HHMMSS_mmm` ディレクトリを作成し、WAVを `output.wav`、合成元のMML原文をUTF-8の `output.mml` として出力する。MCPの戻り値には従来どおりWAVのパスだけを含める。
**`action: "validate"`** — MMLの構文チェックのみ(音声生成なし)
@@ -674,7 +674,7 @@ NoteSequence IRを入力としてPulse1、Pulse2、Triangle、Noiseを合成しã
| 4 | BNF | [MML_BNF.md](MML_BNF.md) を受理言語の正本とし、適合テストで実装との一致を保証 |
| 5 | pyxel `@` とトラック番号の矛盾 | トラック番号優先。`@` はPulse系のみデューティ比として有効 |
| 6 | Noise note_number → period マッピング | ppmck: `period=8, mode=0` 固定。pyxel: マッピング式適用 |
-| 7 | WAVファイル名 | `output_YYYYMMDD_HHMMSS_mmm.wav` |
+| 7 | 出力構成 | `YYYYMMDD_HHMMSS_mmm/output.wav`, `YYYYMMDD_HHMMSS_mmm/output.mml` |
---
diff --git a/doc/mcp.md b/doc/mcp.md
index c6cc682..a59c340 100644
--- a/doc/mcp.md
+++ b/doc/mcp.md
@@ -36,7 +36,7 @@ MML文字列を解析し、以下のいずれかの処理を実行します。
```json
{
"success": true,
- "wav_path": "./data/output.wav",
+ "wav_path": "./data/20260711_120000_123/output.wav",
"duration_sec": 2.0,
"note_sequence": { ... },
"validation": {
diff --git a/pyproject.toml b/pyproject.toml
index 9eb1ab2..6db590b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "mml-composemusic-mcp"
-version = "0.2.0"
+version = "0.2.1"
description = "MCP server for LLM-driven retro chiptune-style MML composition"
readme = "README.md"
requires-python = ">=3.14"
diff --git a/samples/README.md b/samples/README.md
new file mode 100644
index 0000000..6095eeb
--- /dev/null
+++ b/samples/README.md
@@ -0,0 +1,11 @@
+実際にLLMを使用し、作曲させてみたサンプルデータです。
+それぞれppmckとpyxelモードでLLMが生成したmmlとそこから出力されたwavファイルが格納されています。
+
+バロック風の曲を作曲したということらしいです。
+GLM-5.2に当ツールを使用させ、出力された音声データをChatGPTに何度かレビューさせながら作成しました。
+当ツール制作者はあまり音楽に詳しくないので本当にバロック風なのかはわかりません。。。
+
+環境:
+- mml-composemusic-mcp v0.2.2
+- Ollama (glm-5.2:cloud)
+- ChatGPT (GPT5.6 sol) \ No newline at end of file
diff --git a/samples/ppmck/output.mml b/samples/ppmck/output.mml
new file mode 100644
index 0000000..7d82f8f
--- /dev/null
+++ b/samples/ppmck/output.mml
@@ -0,0 +1,86 @@
+#TITLE "Invention in A minor No.4"
+#COMPOSER "Luno"
+
+; Subject: A-B-C-B-A-G#-A (stepwise neighbor tones)
+; Counter-subject: E-A-G-F-E (4th leap! contrasting with subject)
+; G# = g+ (not gs, which conflicts with sweep command s)
+
+A t108 l8 o5 v13 @2
+ a b c b a g+ a2
+ e a g f e2 r
+ c4 r4
+ a b c b a g+ a2
+ c b a g f e d c
+ r2 r2
+ a b c b a g+ a2
+ e4 r4
+ e a g f e2 r
+ a b >c <b a g f e
+ r2 r2
+ c4 r4
+ a b c b a g+ a2
+ e a g f e2 r
+ c b a g f e d c
+ r2 r2
+ a b c b a g+ a2
+ e a g f e2 r
+ c b a g f e d c
+ e f e f e f e f
+ a b a b a b a b
+ a4 r4
+
+B t108 l8 o4 v10 @1
+ r2 r2
+ a b c b a g+ a2
+ e a g f e2 r
+ e4 r4
+ c d e f g a b >c
+ a b c b a g+ a2
+ e4 r4
+ e a g f e2 r
+ c4 r4
+ e d c b a g f e
+ o5 a b c b a g+ a2 o4
+ e a g f e2 r
+ e4 r4
+ c4 r4
+ c d e f g a b >c
+ a b c b a g+ a2
+ e a g f e2 r
+ c4 r4
+ c d e f g a b >c
+ a2 e2 a2 e2
+ a2 e2 a2 e2
+ a4 r4
+
+T l2 o3 v7
+ a e a e a e a e
+ a e a e a e a e
+ f c g e a e a e
+ d e a e a e a e
+ a e a e a e a e
+ a e a e
+
+N l8 v6
+ c r r r c r r r
+ c r r r c r r r
+ c r r r c r r r
+ c r r r c r r r
+ c r c r c r c r
+ c r r r c r r r
+ c r r r c r r r
+ c r r r c r r r
+ c r r r c r r r
+ c r c r c r c r
+ c r r r c r r r
+ c r r r c r r r
+ c r r r c r r r
+ c r r r c r r r
+ c r c r c r c r
+ c r r r c r r r
+ c r r r c r r r
+ c r r r c r r r
+ c r c r c r c r
+ c r r r c r r r
+ c r r r c r r r
+ c r r r r r r r \ No newline at end of file
diff --git a/samples/ppmck/output.wav b/samples/ppmck/output.wav
new file mode 100644
index 0000000..b6b260c
--- /dev/null
+++ b/samples/ppmck/output.wav
Binary files differ
diff --git a/samples/pyxel/output.mml b/samples/pyxel/output.mml
new file mode 100644
index 0000000..68c7dac
--- /dev/null
+++ b/samples/pyxel/output.mml
@@ -0,0 +1,79 @@
+0: T108 L8 O5 V100 @1
+ A B C B A G+ A2
+ E A G F E2 R
+ C4 R4
+ A B C B A G+ A2
+ C B A G F E D C
+ R2 R2
+ A B C B A G+ A2
+ E4 R4
+ E A G F E2 R
+ A B >C <B A G F E
+ R2 R2
+ C4 R4
+ A B C B A G+ A2
+ E A G F E2 R
+ C B A G F E D C
+ R2 R2
+ A B C B A G+ A2
+ E A G F E2 R
+ C B A G F E D C
+ E F E F E F E F
+ A B A B A B A B
+ A4 R4
+
+1: L8 O4 V80 @2
+ R2 R2
+ A B C B A G+ A2
+ E A G F E2 R
+ E4 R4
+ C D E F G A B >C
+ A B C B A G+ A2
+ E4 R4
+ E A G F E2 R
+ C4 R4
+ E D C B A G F E
+ O5 A B C B A G+ A2 O4
+ E A G F E2 R
+ E4 R4
+ C4 R4
+ C D E F G A B >C
+ A B C B A G+ A2
+ E A G F E2 R
+ C4 R4
+ C D E F G A B >C
+ A2 E2 A2 E2
+ A2 E2 A2 E2
+ A4 R4
+
+2: L2 O3 V60
+ A E A E A E A E
+ A E A E A E A E
+ F C G E A E A E
+ D E A E A E A E
+ A E A E A E A E
+ A E A E
+
+3: L8 V50
+ C R R R C R R R
+ C R R R C R R R
+ C R R R C R R R
+ C R R R C R R R
+ C R C R C R C R
+ C R R R C R R R
+ C R R R C R R R
+ C R R R C R R R
+ C R R R C R R R
+ C R C R C R C R
+ C R R R C R R R
+ C R R R C R R R
+ C R R R C R R R
+ C R R R C R R R
+ C R C R C R C R
+ C R R R C R R R
+ C R R R C R R R
+ C R R R C R R R
+ C R C R C R C R
+ C R R R C R R R
+ C R R R C R R R
+ C R R R R R R R \ No newline at end of file
diff --git a/samples/pyxel/output.wav b/samples/pyxel/output.wav
new file mode 100644
index 0000000..9f93c7f
--- /dev/null
+++ b/samples/pyxel/output.wav
Binary files differ
diff --git a/src/mml_composemusic_mcp/ir.py b/src/mml_composemusic_mcp/ir.py
index d9a2f93..8e9b0d0 100644
--- a/src/mml_composemusic_mcp/ir.py
+++ b/src/mml_composemusic_mcp/ir.py
@@ -50,6 +50,7 @@ class ErrorCode(Enum):
# --- Runtime errors (Synthesizer phase) ---
RUNTIME_SYNTHESIS_FAILED = "RUNTIME_SYNTHESIS_FAILED"
RUNTIME_WAV_WRITE_FAILED = "RUNTIME_WAV_WRITE_FAILED"
+ RUNTIME_MML_WRITE_FAILED = "RUNTIME_MML_WRITE_FAILED"
RUNTIME_INTERNAL_ERROR = "RUNTIME_INTERNAL_ERROR"
# --- API-level errors (parameter validation, not MML syntax) ---
diff --git a/src/mml_composemusic_mcp/server.py b/src/mml_composemusic_mcp/server.py
index 45bae68..e3612da 100644
--- a/src/mml_composemusic_mcp/server.py
+++ b/src/mml_composemusic_mcp/server.py
@@ -1,7 +1,7 @@
# MCP server exposing compose_mml tool.
import argparse
-from datetime import datetime
+from datetime import datetime, timedelta
from pathlib import Path
from fastmcp import FastMCP
@@ -23,17 +23,24 @@ def _is_error(errors: list[ErrorDetail]) -> bool:
return any(e.severity == "error" for e in errors)
-def _generate_wav_filename() -> str:
- """Generate a unique WAV filename based on the current timestamp.
+def _create_output_directory() -> Path:
+ """Create and return a unique timestamped output directory.
- Format: output_YYYYMMDD_HHMMSS_mmm.wav
- Milliseconds are included to reduce the chance of collisions when
- multiple compose requests are issued in rapid succession.
+ Format: YYYYMMDD_HHMMSS_mmm
+ If the millisecond is already in use, advance the timestamp until a free
+ directory can be created. Exclusive creation also handles concurrent calls.
"""
now = datetime.now()
- # %f gives microseconds (6 digits); keep the first 3 for milliseconds.
- timestamp = now.strftime("%Y%m%d_%H%M%S_") + now.strftime("%f")[:3]
- return f"output_{timestamp}.wav"
+ for offset_ms in range(1000):
+ candidate_time = now + timedelta(milliseconds=offset_ms)
+ timestamp = candidate_time.strftime("%Y%m%d_%H%M%S_%f")[:-3]
+ output_dir = OUTPUT_DIR / timestamp
+ try:
+ output_dir.mkdir(parents=True, exist_ok=False)
+ return output_dir
+ except FileExistsError:
+ continue
+ raise RuntimeError("一意な出力ディレクトリを作成できませんでした。")
def _split_errors(errors: list[ErrorDetail]) -> tuple[list[dict], list[dict]]:
@@ -173,9 +180,25 @@ def compose_mml(
"note_sequence": note_sequence_dict,
"validation": {"errors": errors_list, "warnings": warnings_list},
}
- wav_path = OUTPUT_DIR / _generate_wav_filename()
+ output_dir = _create_output_directory()
+ wav_path = output_dir / "output.wav"
wav_errors = write_wav(wav_path, wave_data, sample_rate)
errors.extend(wav_errors)
+ if not _is_error(errors):
+ try:
+ (output_dir / "output.mml").write_text(mml, encoding="utf-8")
+ except Exception as exc:
+ errors.append(
+ ErrorDetail(
+ code=ErrorCode.RUNTIME_MML_WRITE_FAILED,
+ phase=ErrorPhase.RUNTIME,
+ line=0,
+ column=0,
+ message=f"MMLファイルの出力に失敗しました: {exc}",
+ severity="error",
+ hint="出力先を確認してください。",
+ )
+ )
errors_list, warnings_list = _split_errors(errors)
return {
"success": not _is_error(errors),
diff --git a/tests/test_compose_mml_tool.py b/tests/test_compose_mml_tool.py
index be4735e..944711f 100644
--- a/tests/test_compose_mml_tool.py
+++ b/tests/test_compose_mml_tool.py
@@ -174,8 +174,8 @@ def test_compose_normalize(compose_mml, tmp_output_dir):
assert Path(off["wav_path"]).exists()
-def test_compose_unique_filename(compose_mml, tmp_output_dir):
- """Each compose call should produce a distinct WAV filename."""
+def test_compose_creates_timestamped_output_directory(compose_mml, tmp_output_dir):
+ """Compose writes the WAV and source MML in a timestamped directory."""
mml = """A t120 l4 o4 v15 q2
c4
"""
@@ -188,20 +188,23 @@ def test_compose_unique_filename(compose_mml, tmp_output_dir):
path2 = result2["wav_path"]
assert path1 is not None
assert path2 is not None
- assert path1 != path2, "Two compose calls produced the same wav_path"
+ assert path1 != path2, "Two compose calls produced the same output directory"
p1 = Path(path1)
p2 = Path(path2)
assert p1.exists()
assert p2.exists()
- assert p1.name.startswith("output_")
- assert p1.name.endswith(".wav")
- assert p2.name.startswith("output_")
- assert p2.name.endswith(".wav")
-
- # Ensure the filename follows the expected timestamp pattern.
+ assert p1.name == "output.wav"
+ assert p2.name == "output.wav"
+ assert p1.parent != p2.parent
+ assert p1.parent.parent == tmp_output_dir
+ assert p2.parent.parent == tmp_output_dir
+ assert (p1.parent / "output.mml").read_text(encoding="utf-8") == mml
+ assert (p2.parent / "output.mml").read_text(encoding="utf-8") == mml
+
+ # Ensure the directory follows the expected timestamp pattern.
import re
- pattern = re.compile(r"^output_\d{8}_\d{6}_\d{3}\.wav$")
- assert pattern.match(p1.name)
- assert pattern.match(p2.name)
+ pattern = re.compile(r"^\d{8}_\d{6}_\d{3}$")
+ assert pattern.match(p1.parent.name)
+ assert pattern.match(p2.parent.name)