summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-07-12 18:28:50 +0900
committertanaka takashi <isazaka@gmail.com>2026-07-12 18:28:50 +0900
commit3d06bee12a26bc55cd73b1494e4d008a8098f6f5 (patch)
treedd13a8f3fcd9d6dbf74d6406bfaa8894895af0ce
parentbf36a773918959eeb6ffe7dae2fb8b0874438eb4 (diff)
feat: MML拡張合成とNTSC 2A03準拠のIR v2音源を実装
- エンベロープ、LFO、ビブラート、グライドのグローバル定義表を追加 - ppmckおよびPyxelの拡張コマンドを音声合成へ反映 - 2A03のPulseデューティ列、Triangle DAC、Noise LFSR、Sweepを実装 - APUタイマーに基づく周波数量子化と非線形ミキサを追加 - 途中テンポ変更、拡張コマンドの検証、トラック外定義に対応 - APU規格、拡張合成、IR v2のテストを追加 - プロジェクト独自のppmck拡張仕様と対応範囲を文書化
-rw-r--r--doc/Design.md20
-rw-r--r--doc/MML_BNF.md27
-rw-r--r--doc/mcp.md4
-rw-r--r--src/mml_composemusic_mcp/ast_nodes.py1
-rw-r--r--src/mml_composemusic_mcp/ir.py18
-rw-r--r--src/mml_composemusic_mcp/lexer.py2
-rw-r--r--src/mml_composemusic_mcp/parser_ppmck.py30
-rw-r--r--src/mml_composemusic_mcp/semantic_base.py3
-rw-r--r--src/mml_composemusic_mcp/semantic_ppmck.py347
-rw-r--r--src/mml_composemusic_mcp/semantic_pyxel.py69
-rw-r--r--src/mml_composemusic_mcp/server.py2
-rw-r--r--src/mml_composemusic_mcp/synthesizer.py693
-rw-r--r--tests/test_mml_conformance.py49
-rw-r--r--tests/test_parser_detailed.py39
-rw-r--r--tests/test_synthesizer.py98
15 files changed, 934 insertions, 468 deletions
diff --git a/doc/Design.md b/doc/Design.md
index 525a22e..eab87aa 100644
--- a/doc/Design.md
+++ b/doc/Design.md
@@ -16,7 +16,7 @@ LLMがMML(Music Macro Language)を記述することで、レトロチップ
|---|---|
| **LLMフレンドリ** | LLMが生成しやすいMML構文、明確なエラーメッセージ |
| **2モード対応** | `ppmck`モード(クラシックAPU準拠)と`pyxel`モード(Pyxel準拠)を切り替え可能 |
-| **正確な音源エミュレーション** | レトロAPU 4チャンネルの特性を忠実に再現 |
+| **正確な音源エミュレーション** | NTSC 2A03 timer、段階波形、Noise LFSR、Sweep、非線形ミキサを再現 |
| **拡張性** | 共通AST・IRによりモード別の構文と音声合成を分離 |
| **単一ツール** | `compose_mml` の1ツールで作曲・検証・テンプレート生成を完結 |
@@ -25,7 +25,7 @@ LLMがMML(Music Macro Language)を記述することで、レトロチップ
| 対象 | 内容 |
|---|---|
| 実装範囲 | Pulse1 / Pulse2 / Triangle / Noiseの4ch MML解析、WAV合成、バリデーション、テンプレート生成 |
-| 構文拡張 | Pyxel `@ENV` / `@VIB` / `@GLI`、ppmck `@v` / `@@` / `@MP` / `@EP` / `@EN` / `D` / `s` / `v+` / `v-` / `^` をIRへ保持し、合成未対応のwarningを返す |
+| 構文拡張 | Pyxel `@ENV` / `@VIB` / `@GLI`、ppmck `@v` / `@@` / `@MP` / `@EP` / `@EN` / `D` / `s` / `v+` / `v-` / `^` をIR v2から合成へ反映 |
| 対象外 | GUI / Webフロントエンド、DPCM、NSF出力、未定義のppmck拡張構文 |
### 1.4 技術スタック
@@ -289,16 +289,16 @@ MML文字列
| ゲートタイム | `q`+数値(ppmck 1-8), `Q` + 数値 (pyxel 0-100) | |
| トランスポーズ | `K` + 符号付き整数 (pyxel) | 例: `K-12`, `K+7` |
| ディチューン | `D` + 符号付き整数 (ppmck), `Y` + 符号付き整数 (pyxel) | 例: `D-10`, `Y-25` |
-| 相対音量 | `v+` / `v-` (ppmck) | IR保持のみ |
-| スイープ | `s`+数値,数値 (ppmck) | IR保持のみ |
+| 相対音量 | `v+` / `v-` (ppmck) | 0〜15へクランプして合成 |
+| スイープ | `s`+数値,符号付き数値 (ppmck) | 2A03 Pulse sweepへ反映 |
| タイ | `^` (ppmck), `&` (両モード) | `^`はタイ、`&`はスラー |
| リピート | `[`, `]` + 数値 | ppmckはASTへ保持してwarning、Pyxelは展開 |
-| エンベロープ/LFO | `@v`/`@@`/`@MP`/`@EP`/`@EN` (ppmck), `@ENV`/`@VIB`/`@GLI` (pyxel) | IR保持のみ、合成未対応warning |
+| エンベロープ/LFO | `@v`/`@@`/`@MP`/`@EP`/`@EN` (ppmck), `@ENV`/`@VIB`/`@GLI` (pyxel) | IR v2定義表を参照して合成 |
| 小節線 | `\|` | 視覚用、再生に影響しない |
| トラック識別 | ppmck: `A`/`B`/`T`/`N`/`L`, pyxel: `数字:` | |
| ヘッダー | `#` で始まる行 (ppmck) | |
| コメント | `;` 以降行末 (ppmck) | |
-| 拡張コマンド | `@ENV`/`@VIB`/`@GLI` (pyxel) | 空白区切りの数値引数をIRへ保持、合成未対応warning |
+| 拡張コマンド | `@ENV`/`@VIB`/`@GLI` (pyxel) | 空白区切りの数値引数をIR v2へ保持して合成 |
| 不正文字 | `INVALID` | Parserが構文エラーとして報告 |
### 5.3 Parser(モード別)
@@ -349,7 +349,7 @@ SemanticAnalyzerの責務は**ASTを検証・解釈してNoteSequence IRを生æˆ
- 音量 `V0-V127` → `0-15` 正規化
- ゲートタイム `Q0-Q100` → `0.0-1.0`
- `@` コマンドのチャンネル適合性チェック
-- `@ENV`/`@VIB`/`@GLI` のIR保持と合成未対応warning
+- `@ENV`/`@VIB`/`@GLI` のIR v2定義、選択、解除、合成
- 無限リピートの2回打ち切りwarning
- `&` スラーを前の音符の音長延長として解釈
@@ -631,7 +631,7 @@ class ErrorPhase(Enum):
BNFの各規則は Lexer → AST → IR/診断の一貫経路で検証する。`tests/test_mml_conformance.py`は、音符・休符・コマンド・トラック・ヘッダー・拡張コマンドについて正例、負例、境界値、位置情報を検査する。Hypothesisによる固定seedの生成テストで、有効音列のAST/IR保存と単一トークン破壊の非黙殺も確認する。
-ppmckの将来予約構文は現在の受理言語に含めない。ppmckの `@v`/`@@`/`@MP`/`@EP`/`@EN` および `D`/`s`/`v+`/`v-` は構文解析してIRへ保持し、合成未対応を `SEMANTIC_UNSUPPORTED_FEATURE` でwarningとして返す。Pyxel拡張も同様に空白区切り引数を受理し、AST/IRへ保持したうえで `SEMANTIC_UNSUPPORTED_FEATURE` warningを返す。
+ppmckの将来予約構文は現在の受理言語に含めない。ppmck/pyxelの拡張はIR v2の共有定義表とチャンネル制御イベントへ変換して合成する。ppmckは構文モード名であり、PPMCK/mckcとのドライバ互換は保証しない。
## 9. APU音声合成エンジン
@@ -658,8 +658,8 @@ NoteSequence IRを入力としてPulse1、Pulse2、Triangle、Noiseを合成しã
| **デューティ比** | `@0`〜`@3` で4種(Pulse系のみ) | `@0`〜`@3`(Pulse系のみ有効) |
| **クオンタイズ/ゲート** | `q1`〜`q8`(gate_time = value / 8) | `Q0`〜`Q100`(%) |
| **タイ/スラー** | `^` = タイ、`&` = スラー | `&` = スラー(タイなし) |
-| **ディチューン** | `D-127`〜`D126`(IR保持のみ) | `Y-127`〜`Y127`(セント) |
-| **エンベロープ/LFO** | `@v`/`@@`/`@MP`/`@EP`/`@EN`(IR保持のみ) | `@ENV`/`@VIB`/`@GLI`(IR保持のみ) |
+| **ディチューン** | `D-127`〜`D126`(セント) | `Y-127`〜`Y127`(セント) |
+| **エンベロープ/LFO** | `@v`/`@@`/`@MP`/`@EP`/`@EN`(合成対応) | `@ENV`/`@VIB`/`@GLI`(合成対応) |
| **リピート** | `L`(構文上のループトラック)、`[...]`(AST保持・warning、合成未反映) | `[...]`(回数指定、省略時は2回で打ち切り、ネスト対応) |
---
diff --git a/doc/MML_BNF.md b/doc/MML_BNF.md
index 83a1281..0c3f3b0 100644
--- a/doc/MML_BNF.md
+++ b/doc/MML_BNF.md
@@ -102,7 +102,7 @@
<comment> ::= ";" <string_to_eol>
```
-### 2.3 拡張コマンド(第1段階: IR 保持のみ、合成無視)
+### 2.3 合成拡張コマンド
```bnf
<ppmck_ext_cmd> ::= <relative_volume_cmd>
@@ -127,7 +127,7 @@
| "v-" <number>? /* 1 〜 15 */
<detune_cmd> ::= "D" <signed_number> /* -127 〜 126 */
-<sweep_cmd> ::= "s" <number> "," <number>
+<sweep_cmd> ::= "s" <number> "," <signed_number>
<vol_envelope_def>::= "@v" <number> "=" "{" <number_list> "|" <number_list>? "}"
<vol_envelope_use>::= "@v" <number>
@@ -224,7 +224,7 @@
<tie_cmd> ::= <tie> (<note> | <rest> | <number>)
```
-### 3.3 拡張コマンド(第1段階: IR 保持のみ、合成無視)
+### 3.3 合成拡張コマンド
```bnf
<ext_cmd> ::= <env_cmd> | <vib_cmd> | <gli_cmd>
@@ -261,19 +261,19 @@
| `l` / `L` | 両方 | 1 〜 192 | 4 | |
| `v` | ppmck | 0 〜 15 | 15 | |
| `V` | pyxel | 0 〜 127 | 100 | |
-| `v+` / `v-` | ppmck | 1 〜 15 | 1 | 相対音量(IR保持のみ) |
+| `v+` / `v-` | ppmck | 1 〜 15 | 1 | 相対音量(0〜15へクランプ) |
| `@` | ppmck | 0 〜 3 | 2 | デューティ比(Pulse系のみ) |
| `@` | pyxel | 0 〜 3 | — | デューティ比(Pulse系のみ) |
| `q` | ppmck | 1 〜 8 | 8 | クオンタイズ(gate_time = value / 8) |
| `Q` | pyxel | 0 〜 100 | 80 | ゲートタイム(%) |
| `t` / `T` | 両方 | 1 〜 | 120 | |
-| `D` | ppmck | -127 〜 126 | 0 | ディチューン(IR保持のみ) |
-| `s` | ppmck | 0 〜 15, 0 〜 7 | — | スイープ `s<x>,<y>`(IR保持のみ) |
-| `@v` | ppmck | 0 〜 255 | — | 音量エンベロープ(IR保持のみ) |
-| `@@` | ppmck | 0 〜 255 | — | デューティエンベロープ(IR保持のみ) |
-| `@MP` / `MP` / `MPOF` | ppmck | 0 〜 255 | — | LFO(IR保持のみ) |
-| `@EP` / `EP` / `EPOF` | ppmck | 0 〜 255 | — | ピッチエンベロープ(IR保持のみ) |
-| `@EN` / `EN` / `ENOF` | ppmck | 0 〜 255 | — | ノートエンベロープ(IR保持のみ) |
+| `D` | ppmck | -127 〜 126 | 0 | cent単位ディチューン |
+| `s` | ppmck | speed 0〜7, depth ±1〜±7 | — | Pulseハードウェアスイープ |
+| `@v` | ppmck | 0 〜 255 | — | 48 tick単位の音量エンベロープ |
+| `@@` | ppmck | 0 〜 255 | — | 48 tick単位のデューティエンベロープ |
+| `@MP` / `MP` / `MPOF` | ppmck | 0 〜 255 | — | 三角LFO |
+| `@EP` / `EP` / `EPOF` | ppmck | 0 〜 255 | — | cent単位ピッチエンベロープ |
+| `@EN` / `EN` / `ENOF` | ppmck | 0 〜 255 | — | 絶対半音オフセット列 |
| `K` | pyxel | -127 〜 127 | 0 | トランスポーズ(半音) |
| `Y` | pyxel | -127 〜 127 | 0 | ディチューン(セント) |
@@ -283,8 +283,9 @@
- 本 BNF は `mml-composemusic-mcp` の**サポート範囲**を定義するものであり、オリジナルの PPMCK や Pyxel の全機能を網羅するものではない。
- 第2段階予定コマンドは設計予約として記載するが、現在の受理文法には含めない。
-- ppmck モードの `@v` / `@@` / `@MP` / `@EP` / `@EN`、および `D` / `s` / `v+` / `v-` は構文解析して IR へ保持し、合成には反映しない。未対応を `SEMANTIC_UNSUPPORTED_FEATURE` で警告する。
-- pyxel モードの `@ENV` / `@VIB` / `@GLI` も同様に IR 保持のみで、`SEMANTIC_UNSUPPORTED_FEATURE` 警告を返す。
+- ppmck モード名は構文互換の識別子であり、拡張の挙動は本プロジェクト独自仕様である。PPMCK/mckc互換は保証しない。
+- ppmck の列型エンベロープは1要素48 tickで、`|`以降を反復し、反復がなければ末尾値を保持する。`D`/`@EP`はcent、`@EN`は絶対半音、`@MP`は`delay,period,depth,0`である。
+- pyxel の `@ENV` は`target,duration`組、`@VIB`は`delay,period,depth_cents`、`@GLI`は`initial_offset_cents,duration`である。slot 0は解除を表す。
- コマンド文字は ppmck では小文字、pyxel では大文字を使用する。音符・休符も同じ規則に従う。ただし ppmck の `D`(ディチューン)は大文字のみ受理する(小文字 `d` は音符)。
- `^` は ppmck モード専用のタイ、`&` は両モード共通のスラー(slur)として扱う。
- 詳細な実装アーキテクチャは [Design.md](Design.md) を参照。
diff --git a/doc/mcp.md b/doc/mcp.md
index fbc8dbd..ecdda8e 100644
--- a/doc/mcp.md
+++ b/doc/mcp.md
@@ -52,6 +52,10 @@ MML文字列を解析し、以下のいずれかの処理を実行します。
| `wav_path` | string \| null | 生成されたWAVファイルのパス(失敗時はnull) |
| `duration_sec` | number | 生成された音声の長さ(秒) |
| `note_sequence` | object \| null | 中間表現(IR)のNoteSequence |
+
+`note_sequence`はIR v2で返される。曲全体で共有するエンベロープ/LFO定義は
+`definitions`、選択・解除と演奏イベントは各`channels.*.events`に格納される。
+`ppmck`は構文モード名であり、拡張コマンドのPPMCK/mckc互換を保証しない。
| `validation.errors` | array | エラーのリスト |
| `validation.warnings` | array | 警告のリスト |
diff --git a/src/mml_composemusic_mcp/ast_nodes.py b/src/mml_composemusic_mcp/ast_nodes.py
index 3341510..dc85e79 100644
--- a/src/mml_composemusic_mcp/ast_nodes.py
+++ b/src/mml_composemusic_mcp/ast_nodes.py
@@ -17,6 +17,7 @@ class Program(ASTNode):
"""Root program node."""
tracks: list[Track] = field(default_factory=list)
+ global_statements: list[Statement] = field(default_factory=list)
@dataclass
diff --git a/src/mml_composemusic_mcp/ir.py b/src/mml_composemusic_mcp/ir.py
index 4dd7978..d9a2f93 100644
--- a/src/mml_composemusic_mcp/ir.py
+++ b/src/mml_composemusic_mcp/ir.py
@@ -44,6 +44,7 @@ class ErrorCode(Enum):
SEMANTIC_EMPTY_TRACK = "SEMANTIC_EMPTY_TRACK"
SEMANTIC_OUTSIDE_TRACK = "SEMANTIC_OUTSIDE_TRACK"
SEMANTIC_UNDEFINED_REFERENCE = "SEMANTIC_UNDEFINED_REFERENCE"
+ SEMANTIC_DUPLICATE_DEFINITION = "SEMANTIC_DUPLICATE_DEFINITION"
SEMANTIC_UNSUPPORTED_FEATURE = "SEMANTIC_UNSUPPORTED_FEATURE"
# --- Runtime errors (Synthesizer phase) ---
@@ -279,9 +280,22 @@ class ChannelSequence:
@dataclass
class NoteSequence:
- version: str = "1.0"
+ version: str = "2.0"
bpm: int = 120
ticks_per_quarter: int = 192
+ mode: str = "ppmck"
+ definitions: dict[str, dict[int, Any]] = field(
+ default_factory=lambda: {
+ "volume_envelopes": {},
+ "duty_envelopes": {},
+ "pitch_envelopes": {},
+ "note_envelopes": {},
+ "lfos": {},
+ "envelopes": {},
+ "vibratos": {},
+ "glides": {},
+ }
+ )
channels: dict[str, ChannelSequence] = field(
default_factory=lambda: {
"Pulse1": ChannelSequence("pulse"),
@@ -296,6 +310,8 @@ class NoteSequence:
"version": self.version,
"bpm": self.bpm,
"ticks_per_quarter": self.ticks_per_quarter,
+ "mode": self.mode,
+ "definitions": self.definitions,
"channels": {k: v.to_dict() for k, v in self.channels.items()},
}
diff --git a/src/mml_composemusic_mcp/lexer.py b/src/mml_composemusic_mcp/lexer.py
index e5f08a8..ed0a26e 100644
--- a/src/mml_composemusic_mcp/lexer.py
+++ b/src/mml_composemusic_mcp/lexer.py
@@ -329,7 +329,7 @@ class Lexer:
num0 = self._read_number()
if self._peek() == ",":
self._advance()
- num1 = self._read_number()
+ num1 = self._read_signed_number()
raw = f"s{num0},{num1}"
self._emit(TokenType.SWEEP, f"{num0},{num1}", raw)
return
diff --git a/src/mml_composemusic_mcp/parser_ppmck.py b/src/mml_composemusic_mcp/parser_ppmck.py
index 47a0412..bd3b06b 100644
--- a/src/mml_composemusic_mcp/parser_ppmck.py
+++ b/src/mml_composemusic_mcp/parser_ppmck.py
@@ -97,6 +97,36 @@ class PpmckParser:
if token.type == TokenType.INVALID:
self._handle_invalid(token)
continue
+ if token.type in {
+ TokenType.VOL_ENV,
+ TokenType.DUTY,
+ TokenType.LFO_DEF,
+ TokenType.PITCH_ENV_DEF,
+ TokenType.NOTE_ENV_DEF,
+ }:
+ statement = self._parse_statement()
+ if isinstance(
+ statement,
+ (
+ VolumeEnvelopeDefStmt,
+ DutyEnvelopeDefStmt,
+ LfoDefStmt,
+ PitchEnvDefStmt,
+ NoteEnvDefStmt,
+ ),
+ ):
+ program.global_statements.append(statement)
+ else:
+ self.ctx.add_error(
+ code=ErrorCode.SEMANTIC_OUTSIDE_TRACK,
+ line=token.line,
+ column=token.column,
+ message="トラック外ではエフェクト定義だけを使用できます。",
+ severity="error",
+ hint="選択コマンドはトラック内へ移動してください。",
+ context=context_line(self.source, token),
+ )
+ continue
# Anything else before a track header is outside a track.
self._warn_outside_track(token)
self.ctx.advance()
diff --git a/src/mml_composemusic_mcp/semantic_base.py b/src/mml_composemusic_mcp/semantic_base.py
index 8b27e89..0a6acdb 100644
--- a/src/mml_composemusic_mcp/semantic_base.py
+++ b/src/mml_composemusic_mcp/semantic_base.py
@@ -97,6 +97,9 @@ class SemanticAnalyzer:
self.tick_position = 0
def analyze(self) -> tuple[NoteSequence, list[ErrorDetail]]:
+ self.current_channel = None
+ for stmt in self.program.global_statements:
+ self._analyze_statement(stmt)
for track in self.program.tracks:
self._analyze_track(track)
self._finalize()
diff --git a/src/mml_composemusic_mcp/semantic_ppmck.py b/src/mml_composemusic_mcp/semantic_ppmck.py
index e2cabf9..4171a4b 100644
--- a/src/mml_composemusic_mcp/semantic_ppmck.py
+++ b/src/mml_composemusic_mcp/semantic_ppmck.py
@@ -63,10 +63,50 @@ PULSE_CHANNELS = {"Pulse1", "Pulse2"}
class PpmckSemanticAnalyzer(SemanticAnalyzer):
def __init__(self, source: str, program: Program) -> None:
super().__init__(source, program)
+ self.note_sequence.mode = "ppmck"
self.loop_start: int | None = None
+ self.detune_cents = 0.0
+
+ def _define(self, kind: str, slot: int, value: dict) -> None:
+ table = self.note_sequence.definitions[kind]
+ if slot in table:
+ self.ctx.add_error(
+ ErrorCode.SEMANTIC_DUPLICATE_DEFINITION,
+ 0,
+ 0,
+ f"エフェクトスロット {slot} は既に定義されています。",
+ "error",
+ "別のスロット番号を使用してください。",
+ )
+ return
+ table[slot] = value
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())
for ch in ns.channels.values():
@@ -92,6 +132,7 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
self.gate_time = 1.0
self.duty = 2
self.tick_position = 0
+ self.detune_cents = 0.0
def _analyze_track(self, track: Track) -> None:
if track.channel == "Loop":
@@ -131,6 +172,7 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
velocity=self.velocity,
duty=self.duty,
gate_time=self.gate_time,
+ detune_cents=self.detune_cents,
)
self._add_event(event)
self.tick_position += ticks
@@ -255,33 +297,37 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
return
def _analyze_detune_cmd(self, stmt: DetuneCmdStmt) -> None:
+ self.detune_cents = float(stmt.value)
self._add_event(DetuneEvent(tick_position=self.tick_position, value=stmt.value))
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message=f"ディチューン 'D{stmt.value}' はIR保持のみで合成に反映されません。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(stmt),
- )
def _analyze_relative_volume(self, stmt: RelativeVolumeStmt) -> None:
+ self.velocity = clamp(self.velocity + stmt.delta, 0, 15)
self._add_event(
RelativeVolumeEvent(tick_position=self.tick_position, delta=stmt.delta)
)
- sign = "+" if stmt.delta > 0 else "-"
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message=f"相対音量 'v{sign}{abs(stmt.delta)}' はIR保持のみです。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(stmt),
- )
def _analyze_sweep(self, stmt: SweepStmt) -> None:
+ if self.current_channel not in PULSE_CHANNELS:
+ self.ctx.add_error(
+ ErrorCode.SEMANTIC_CHANNEL_MISMATCH,
+ stmt.line,
+ stmt.column,
+ "スイープはPulseチャンネル専用です。",
+ "error",
+ "AまたはBトラックで使用してください。",
+ self._context_line(stmt),
+ )
+ return
+ if not 0 <= stmt.speed <= 7 or not 1 <= abs(stmt.depth) <= 7:
+ self.ctx.add_error(
+ ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ stmt.line,
+ stmt.column,
+ "スイープ値はspeed=0〜7、depth=±1〜±7で指定してください。",
+ "error",
+ context=self._context_line(stmt),
+ )
+ return
self._add_event(
SweepEvent(
tick_position=self.tick_position,
@@ -289,221 +335,141 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
depth=stmt.depth,
)
)
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message=f"スイープ 's{stmt.speed},{stmt.depth}' はIR保持のみです。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(stmt),
- )
def _analyze_vol_env_use(self, stmt: VolumeEnvelopeUseStmt) -> None:
self._add_event(
VolumeEnvelopeEvent(tick_position=self.tick_position, slot=stmt.slot)
)
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message=f"音量エンベロープ '@v{stmt.slot}' はIR保持のみです。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(stmt),
- )
def _analyze_vol_env_def(self, stmt: VolumeEnvelopeDefStmt) -> None:
- self._add_event(
- VolumeEnvelopeEvent(
- tick_position=self.tick_position,
- slot=stmt.slot,
- points=stmt.points,
- loop_points=stmt.loop_points,
- is_definition=True,
- )
- )
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message=f"音量エンベロープ定義 '@v{stmt.slot}={{...}}' はIR保持のみです。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(stmt),
+ self._define(
+ "volume_envelopes",
+ stmt.slot,
+ {"points": stmt.points, "loop_points": stmt.loop_points},
)
+ if self.current_channel is not None:
+ self._add_event(
+ VolumeEnvelopeEvent(
+ tick_position=self.tick_position,
+ slot=stmt.slot,
+ points=stmt.points,
+ loop_points=stmt.loop_points,
+ is_definition=True,
+ )
+ )
def _analyze_duty_env_def(self, stmt: DutyEnvelopeDefStmt) -> None:
- self._add_event(
- DutyEnvelopeEvent(
- tick_position=self.tick_position,
- slot=stmt.slot,
- points=stmt.points,
- loop_points=stmt.loop_points,
- is_definition=True,
- )
- )
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message=f"デューティエンベロープ定義 '@{stmt.slot}={{...}}' はIR保持のみです。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(stmt),
+ self._define(
+ "duty_envelopes",
+ stmt.slot,
+ {"points": stmt.points, "loop_points": stmt.loop_points},
)
+ if self.current_channel is not None:
+ self._add_event(
+ DutyEnvelopeEvent(
+ tick_position=self.tick_position,
+ slot=stmt.slot,
+ points=stmt.points,
+ loop_points=stmt.loop_points,
+ is_definition=True,
+ )
+ )
def _analyze_duty_env_use(self, stmt: DutyEnvelopeUseStmt) -> None:
- self._add_event(
- DutyEnvelopeEvent(tick_position=self.tick_position, slot=stmt.slot)
- )
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message=f"デューティエンベロープ使用 '@@{stmt.slot}' はIR保持のみです。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(stmt),
- )
+ 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:
- self._add_event(
- LfoEvent(
- tick_position=self.tick_position,
- slot=stmt.slot,
- delay=stmt.delay,
- speed=stmt.speed,
- depth=stmt.depth,
- transition=stmt.transition,
- is_definition=True,
+ if stmt.transition != 0 or stmt.speed <= 0:
+ self.ctx.add_error(
+ ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ stmt.line,
+ stmt.column,
+ "@MPはspeed>0、transition=0で指定してください。",
+ "error",
+ context=self._context_line(stmt),
)
+ return
+ self._define(
+ "lfos",
+ stmt.slot,
+ {
+ "delay": stmt.delay,
+ "speed": stmt.speed,
+ "depth": stmt.depth,
+ "transition": stmt.transition,
+ },
)
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message=f"LFO定義 '@MP{stmt.slot}={{...}}' はIR保持のみです。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(stmt),
- )
+ if self.current_channel is not None:
+ self._add_event(
+ LfoEvent(
+ tick_position=self.tick_position,
+ slot=stmt.slot,
+ delay=stmt.delay,
+ speed=stmt.speed,
+ depth=stmt.depth,
+ transition=stmt.transition,
+ is_definition=True,
+ )
+ )
def _analyze_lfo_use(self, stmt: LfoUseStmt) -> None:
self._add_event(LfoEvent(tick_position=self.tick_position, slot=stmt.slot))
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message=f"LFO使用 'MP{stmt.slot}' はIR保持のみです。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(stmt),
- )
def _analyze_lfo_off(self, stmt: LfoOffStmt) -> None:
self._add_event(LfoEvent(tick_position=self.tick_position, slot=0, is_off=True))
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message="LFO解除 'MPOF' はIR保持のみです。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(stmt),
- )
def _analyze_pitch_env_def(self, stmt: PitchEnvDefStmt) -> None:
- self._add_event(
- PitchEnvEvent(
- tick_position=self.tick_position,
- slot=stmt.slot,
- points=stmt.points,
- loop_points=stmt.loop_points,
- is_definition=True,
- )
- )
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message=f"ピッチエンベロープ定義 '@EP{stmt.slot}={{...}}' はIR保持のみです。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(stmt),
+ self._define(
+ "pitch_envelopes",
+ stmt.slot,
+ {"points": stmt.points, "loop_points": stmt.loop_points},
)
+ if self.current_channel is not None:
+ self._add_event(
+ PitchEnvEvent(
+ tick_position=self.tick_position,
+ slot=stmt.slot,
+ points=stmt.points,
+ loop_points=stmt.loop_points,
+ is_definition=True,
+ )
+ )
def _analyze_pitch_env_use(self, stmt: PitchEnvUseStmt) -> None:
self._add_event(PitchEnvEvent(tick_position=self.tick_position, slot=stmt.slot))
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message=f"ピッチエンベロープ使用 'EP{stmt.slot}' はIR保持のみです。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(stmt),
- )
def _analyze_pitch_env_off(self, stmt: PitchEnvOffStmt) -> None:
self._add_event(
PitchEnvEvent(tick_position=self.tick_position, slot=0, is_off=True)
)
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message="ピッチエンベロープ解除 'EPOF' はIR保持のみです。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(stmt),
- )
def _analyze_note_env_def(self, stmt: NoteEnvDefStmt) -> None:
- self._add_event(
- NoteEnvEvent(
- tick_position=self.tick_position,
- slot=stmt.slot,
- points=stmt.points,
- loop_points=stmt.loop_points,
- is_definition=True,
- )
- )
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message=f"ノートエンベロープ定義 '@EN{stmt.slot}={{...}}' はIR保持のみです。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(stmt),
+ self._define(
+ "note_envelopes",
+ stmt.slot,
+ {"points": stmt.points, "loop_points": stmt.loop_points},
)
+ if self.current_channel is not None:
+ self._add_event(
+ NoteEnvEvent(
+ tick_position=self.tick_position,
+ slot=stmt.slot,
+ points=stmt.points,
+ loop_points=stmt.loop_points,
+ is_definition=True,
+ )
+ )
def _analyze_note_env_use(self, stmt: NoteEnvUseStmt) -> None:
self._add_event(NoteEnvEvent(tick_position=self.tick_position, slot=stmt.slot))
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message=f"ノートエンベロープ使用 'EN{stmt.slot}' はIR保持のみです。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(stmt),
- )
def _analyze_note_env_off(self, stmt: NoteEnvOffStmt) -> None:
self._add_event(
NoteEnvEvent(tick_position=self.tick_position, slot=0, is_off=True)
)
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message="ノートエンベロープ解除 'ENOF' はIR保持のみです。",
- severity="warning",
- hint="現時点では無視されます。",
- context=self._context_line(stmt),
- )
def _analyze_tempo(self, stmt: TempoStmt) -> None:
if stmt.value < 1:
@@ -517,7 +483,8 @@ class PpmckSemanticAnalyzer(SemanticAnalyzer):
context=self._context_line(stmt),
)
return
- self.note_sequence.bpm = stmt.value
+ if self.tick_position == 0:
+ self.note_sequence.bpm = stmt.value
self._add_event(TempoEvent(tick_position=self.tick_position, bpm=stmt.value))
def _analyze_gate_time(self, stmt: GateTimeStmt) -> None:
diff --git a/src/mml_composemusic_mcp/semantic_pyxel.py b/src/mml_composemusic_mcp/semantic_pyxel.py
index 7bf94ba..74a6c29 100644
--- a/src/mml_composemusic_mcp/semantic_pyxel.py
+++ b/src/mml_composemusic_mcp/semantic_pyxel.py
@@ -54,10 +54,36 @@ class RepeatFrame:
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
@@ -256,7 +282,8 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer):
context=self._context_line(stmt),
)
return
- self.note_sequence.bpm = stmt.value
+ if self.tick_position == 0:
+ self.note_sequence.bpm = stmt.value
self._add_event(TempoEvent(tick_position=self.tick_position, bpm=stmt.value))
def _analyze_transpose(self, stmt: TransposeStmt) -> None:
@@ -358,7 +385,33 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer):
self._add_event(ev)
return
if stmt.params:
+ table_name = {
+ "ENV": "envelopes",
+ "VIB": "vibratos",
+ "GLI": "glides",
+ }[stmt.cmd]
+ if stmt.slot in self.note_sequence.definitions[table_name]:
+ self.ctx.add_error(
+ ErrorCode.SEMANTIC_DUPLICATE_DEFINITION,
+ stmt.line,
+ stmt.column,
+ f"@{stmt.cmd}スロット {stmt.slot} は既に定義されています。",
+ "error",
+ "別のスロット番号を使用してください。",
+ self._context_line(stmt),
+ )
+ return
if stmt.cmd == "ENV":
+ if len(stmt.params) % 2:
+ self.ctx.add_error(
+ ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE,
+ stmt.line,
+ stmt.column,
+ "@ENVのパラメータはtarget,durationの組で指定してください。",
+ "error",
+ context=self._context_line(stmt),
+ )
+ return
points = []
for i in range(1, len(stmt.params), 2):
duration = stmt.params[i]
@@ -373,6 +426,9 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer):
slot=stmt.slot,
points=points,
)
+ self.note_sequence.definitions["envelopes"][stmt.slot] = {
+ "points": points
+ }
elif stmt.cmd == "VIB":
if len(stmt.params) >= 3:
params = {
@@ -387,6 +443,7 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer):
slot=stmt.slot,
params=params,
)
+ self.note_sequence.definitions["vibratos"][stmt.slot] = params
else:
if len(stmt.params) >= 2:
params = {
@@ -400,6 +457,7 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer):
slot=stmt.slot,
params=params,
)
+ self.note_sequence.definitions["glides"][stmt.slot] = params
self._add_event(ev)
else:
if stmt.cmd == "ENV":
@@ -409,15 +467,6 @@ class PyxelSemanticAnalyzer(SemanticAnalyzer):
else:
ev = GlideEvent(tick_position=self.tick_position, slot=stmt.slot)
self._add_event(ev)
- self.ctx.add_error(
- code=ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE,
- line=stmt.line,
- column=stmt.column,
- message=f"@{stmt.cmd} は第1段階では未サポート、無視されます。",
- severity="warning",
- hint="合成には反映されませんが、IRとして保持されます。",
- context=self._context_line(stmt),
- )
def analyze_pyxel(
diff --git a/src/mml_composemusic_mcp/server.py b/src/mml_composemusic_mcp/server.py
index 9e9ac67..06520a6 100644
--- a/src/mml_composemusic_mcp/server.py
+++ b/src/mml_composemusic_mcp/server.py
@@ -253,6 +253,8 @@ def _dict_to_note_sequence(data: dict) -> NoteSequence:
version=data["version"],
bpm=data["bpm"],
ticks_per_quarter=data["ticks_per_quarter"],
+ mode=data.get("mode", "ppmck"),
+ definitions=data.get("definitions", {}),
channels={},
)
for name, ch_data in data["channels"].items():
diff --git a/src/mml_composemusic_mcp/synthesizer.py b/src/mml_composemusic_mcp/synthesizer.py
index b314aa5..ae98267 100644
--- a/src/mml_composemusic_mcp/synthesizer.py
+++ b/src/mml_composemusic_mcp/synthesizer.py
@@ -1,64 +1,255 @@
-"""Retro APU-style synthesizer: NoteSequence -> WAV."""
+"""Deterministic, minimal NTSC 2A03 synthesizer: NoteSequence -> WAV."""
import wave
-from math import floor
+from dataclasses import dataclass
from pathlib import Path
import numpy as np
from .ir import (
+ DetuneEvent,
+ DutyEnvelopeEvent,
DutyEvent,
+ EnvelopeEvent,
ErrorCode,
ErrorDetail,
ErrorPhase,
+ GlideEvent,
+ LfoEvent,
+ NoteEnvEvent,
NoteEvent,
NoteSequence,
+ PitchEnvEvent,
+ RelativeVolumeEvent,
RestEvent,
+ SweepEvent,
TempoEvent,
+ VibratoEvent,
+ VolumeEnvelopeEvent,
VolumeEvent,
)
CPU_CLOCK = 1_789_772.5
SAMPLE_RATE_DEFAULT = 44100
+ENVELOPE_STEP_TICKS = 48
+NOISE_PERIODS = (
+ 4,
+ 8,
+ 16,
+ 32,
+ 64,
+ 96,
+ 128,
+ 160,
+ 202,
+ 254,
+ 380,
+ 508,
+ 762,
+ 1016,
+ 2034,
+ 4068,
+)
+PULSE_SEQUENCES = (
+ (0, 1, 0, 0, 0, 0, 0, 0),
+ (0, 1, 1, 0, 0, 0, 0, 0),
+ (0, 1, 1, 1, 1, 0, 0, 0),
+ (1, 0, 0, 1, 1, 1, 1, 1),
+)
+TRIANGLE_SEQUENCE = (
+ 15,
+ 14,
+ 13,
+ 12,
+ 11,
+ 10,
+ 9,
+ 8,
+ 7,
+ 6,
+ 5,
+ 4,
+ 3,
+ 2,
+ 1,
+ 0,
+ 0,
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 6,
+ 7,
+ 8,
+ 9,
+ 10,
+ 11,
+ 12,
+ 13,
+ 14,
+ 15,
+)
+
+
+def midi_to_freq(note_number: float) -> float:
+ return 440.0 * 2.0 ** ((note_number - 69.0) / 12.0)
+
+
+def frequency_to_timer(frequency: float, channel_type: str) -> int:
+ divider = 16.0 if channel_type == "pulse" else 32.0
+ if frequency <= 0:
+ return 0x7FF
+ return max(0, min(0x7FF, round(CPU_CLOCK / (divider * frequency) - 1.0)))
+
+
+def timer_to_frequency(timer: int, channel_type: str) -> float:
+ divider = 16.0 if channel_type == "pulse" else 32.0
+ return CPU_CLOCK / (divider * (timer + 1))
+
+
+def noise_period_to_rate(
+ period: int, mode: int = 0, sample_rate: int = SAMPLE_RATE_DEFAULT
+) -> float:
+ del mode, sample_rate
+ return CPU_CLOCK / NOISE_PERIODS[period & 0x0F]
+
+
+def apu_mix(
+ pulse1: np.ndarray, pulse2: np.ndarray, triangle: np.ndarray, noise: np.ndarray
+) -> np.ndarray:
+ pulse_sum = pulse1 + pulse2
+ pulse_out = np.zeros_like(pulse_sum, dtype=np.float64)
+ mask = pulse_sum > 0
+ pulse_out[mask] = 95.88 / (8128.0 / pulse_sum[mask] + 100.0)
+ tnd_input = triangle / 8227.0 + noise / 12241.0
+ tnd_out = np.zeros_like(tnd_input, dtype=np.float64)
+ mask = tnd_input > 0
+ tnd_out[mask] = 159.79 / (1.0 / tnd_input[mask] + 100.0)
+ return pulse_out + tnd_out
+
+
+@dataclass
+class TempoMap:
+ events: list[tuple[int, int]]
+ ticks_per_quarter: int = 192
+
+ def tick_to_second(self, tick: float) -> float:
+ elapsed = 0.0
+ previous_tick = 0
+ bpm = self.events[0][1]
+ for event_tick, event_bpm in self.events[1:]:
+ if tick <= event_tick:
+ break
+ elapsed += (
+ (event_tick - previous_tick) * 60.0 / (bpm * self.ticks_per_quarter)
+ )
+ previous_tick, bpm = event_tick, event_bpm
+ return elapsed + (tick - previous_tick) * 60.0 / (bpm * self.ticks_per_quarter)
+
+ def seconds_to_ticks(self, seconds: np.ndarray) -> np.ndarray:
+ result = np.empty_like(seconds)
+ segment_seconds = 0.0
+ for index, (start_tick, bpm) in enumerate(self.events):
+ end_tick = (
+ self.events[index + 1][0] if index + 1 < len(self.events) else None
+ )
+ end_seconds = (
+ (
+ segment_seconds
+ + (end_tick - start_tick) * 60.0 / (bpm * self.ticks_per_quarter)
+ )
+ if end_tick is not None
+ else np.inf
+ )
+ mask = (seconds >= segment_seconds) & (seconds < end_seconds)
+ result[mask] = (
+ start_tick
+ + (seconds[mask] - segment_seconds)
+ * bpm
+ * self.ticks_per_quarter
+ / 60.0
+ )
+ segment_seconds = end_seconds
+ return result
+
+
+def build_tempo_map(note_sequence: NoteSequence) -> TempoMap:
+ changes = {0: note_sequence.bpm}
+ for channel in note_sequence.channels.values():
+ for event in channel.events:
+ if isinstance(event, TempoEvent):
+ changes[event.tick_position] = event.bpm
+ return TempoMap(sorted(changes.items()), note_sequence.ticks_per_quarter)
+
+
+def tick_to_second(tick: int, bpm: int, ticks_per_quarter: int = 192) -> float:
+ return tick * 60.0 / (bpm * ticks_per_quarter)
-def midi_to_freq(note_number: int) -> float:
- return 440.0 * 2.0 ** ((note_number - 69) / 12.0)
-
-
-def noise_period_to_rate(period: int, mode: int, sample_rate: int) -> float:
- # Retro noise: rate = CPU_CLOCK / (NTSC divider for period)
- # Approximate using pyxel mapping described in design
- dividers = [
- 4,
- 8,
- 16,
- 32,
- 64,
- 96,
- 128,
- 160,
- 202,
- 254,
- 380,
- 508,
- 762,
- 1016,
- 2034,
- 4068,
- ]
- div = dividers[period & 0x0F]
- # Long mode (0) vs short mode (1) affects LFSR length, not frequency divider
- return CPU_CLOCK / div
+def _sequence_value(
+ definition: dict | None, elapsed_ticks: float, default: float
+) -> float:
+ if not definition:
+ return default
+ head = list(definition.get("points", []))
+ loop = list(definition.get("loop_points", []))
+ values = head + loop
+ if not values:
+ return default
+ index = max(0, int(elapsed_ticks // ENVELOPE_STEP_TICKS))
+ if index < len(values):
+ return values[index]
+ if loop:
+ return loop[(index - len(head)) % len(loop)]
+ return values[-1]
+
+
+def _triangle_lfo(elapsed: float, delay: float, period: float, depth: float) -> float:
+ if elapsed < delay or period <= 0:
+ return 0.0
+ phase = ((elapsed - delay) / period) % 1.0
+ return (1.0 - 4.0 * abs(phase - 0.5)) * depth
+
+
+def _definition(table: dict, slot: object) -> dict | None:
+ return table.get(slot) or table.get(str(slot))
+
+
+def _pyxel_envelope(points: list[dict], elapsed: float, initial: float) -> float:
+ value = initial * 127.0 / 15.0
+ remaining = elapsed
+ for point in points:
+ target = point["target_volume"]
+ duration = point["duration_ticks"]
+ if duration <= 0:
+ value = target
+ continue
+ if remaining <= duration:
+ return round(
+ (value + (target - value) * remaining / duration) * 15.0 / 127.0
+ )
+ value = target
+ remaining -= duration
+ return round(value * 15.0 / 127.0)
class ChannelSynthesizer:
def __init__(
- self, channel_type: str, sample_rate: int = SAMPLE_RATE_DEFAULT
+ self,
+ channel_type: str,
+ sample_rate: int = SAMPLE_RATE_DEFAULT,
+ channel_name: str = "Pulse1",
+ noise_mode: int = 0,
) -> None:
self.channel_type = channel_type
+ self.channel_name = channel_name
+ self.noise_mode = 1 if noise_mode else 0
self.sample_rate = sample_rate
self.wave = np.zeros(0, dtype=np.float64)
+ self._triangle_phase = 0.0
+ self._noise_lfsr = 1
+ self._noise_accumulator = 0.0
def render(
self,
@@ -66,162 +257,236 @@ class ChannelSynthesizer:
total_ticks: int,
bpm: int,
mode: str,
+ definitions: dict | None = None,
+ tempo_map: TempoMap | None = None,
) -> np.ndarray:
- seconds = tick_to_second(total_ticks, bpm)
- length = int(seconds * self.sample_rate)
+ definitions = definitions or {}
+ tempo_map = tempo_map or TempoMap([(0, bpm)])
+ length = int(tempo_map.tick_to_second(total_ticks) * self.sample_rate)
self.wave = np.zeros(length, dtype=np.float64)
- last_velocity = 15 if self.channel_type != "triangle" else 15
- last_duty = 2
- for event in events:
- if isinstance(event, VolumeEvent):
- last_velocity = event.value
- continue
- if isinstance(event, DutyEvent):
- last_duty = event.value
- continue
- if isinstance(event, TempoEvent):
- bpm = event.bpm
- continue
- if not isinstance(event, (NoteEvent, RestEvent)):
- continue
- start_sample = int(
- tick_to_second(event.tick_position, bpm) * self.sample_rate
- )
- end_tick = event.tick_position + event.duration
- end_sample = min(
- int(tick_to_second(end_tick, bpm) * self.sample_rate), length
+ if length == 0:
+ return self.wave
+ seconds = np.arange(length, dtype=np.float64) / self.sample_rate
+ ticks = tempo_map.seconds_to_ticks(seconds)
+ notes = [e for e in events if isinstance(e, NoteEvent)]
+ controls = sorted(
+ (
+ e
+ for e in events
+ if not isinstance(e, (NoteEvent, RestEvent, TempoEvent))
+ ),
+ key=lambda e: e.tick_position,
+ )
+ for note in notes:
+ mask = (ticks >= note.tick_position) & (
+ ticks < note.tick_position + note.duration
)
- if start_sample >= length or end_sample <= start_sample:
- continue
- if isinstance(event, RestEvent):
+ if not np.any(mask):
continue
- self._render_note(
- start_sample,
- end_sample,
- event,
- last_velocity,
- last_duty,
- mode,
- )
+ local_ticks = ticks[mask] - note.tick_position
+ indices = np.flatnonzero(mask)
+ self._render_note(indices, local_ticks, note, controls, definitions, mode)
return self.wave
def _render_note(
self,
- start: int,
- end: int,
- event: NoteEvent,
- velocity: int,
- duty: int,
+ indices: np.ndarray,
+ local_ticks: np.ndarray,
+ note: NoteEvent,
+ controls: list,
+ definitions: dict,
mode: str,
) -> None:
- duration_samples = end - start
- if duration_samples <= 0:
- return
+ active = [e for e in controls if e.tick_position <= note.tick_position]
+ volume = (
+ note.velocity
+ if mode == "ppmck"
+ else round(note.velocity * 15 / 127)
+ if note.velocity > 15
+ else note.velocity
+ )
+ duty = note.duty
+ detune = note.detune_cents
+ selections: dict[str, object | None] = {
+ "vol": None,
+ "duty": None,
+ "pitch": None,
+ "note": None,
+ "lfo": None,
+ "env": None,
+ "vib": None,
+ "glide": None,
+ }
+ sweep: SweepEvent | None = None
+ for event in active:
+ if isinstance(event, VolumeEvent):
+ volume = event.value
+ elif isinstance(event, RelativeVolumeEvent):
+ # Semantic analysis already stores the resulting velocity on
+ # each note; this event additionally cancels @v selection.
+ selections["vol"] = None
+ elif isinstance(event, DutyEvent):
+ duty = event.value
+ selections["duty"] = None
+ elif isinstance(event, DetuneEvent):
+ detune = event.value
+ elif isinstance(event, SweepEvent):
+ sweep = event
+ elif isinstance(event, VolumeEnvelopeEvent) and not event.is_definition:
+ selections["vol"] = event.slot
+ elif isinstance(event, DutyEnvelopeEvent) and not event.is_definition:
+ selections["duty"] = event.slot
+ elif isinstance(event, PitchEnvEvent) and not event.is_definition:
+ selections["pitch"] = None if event.is_off else event.slot
+ elif isinstance(event, NoteEnvEvent) and not event.is_definition:
+ selections["note"] = None if event.is_off else event.slot
+ elif isinstance(event, LfoEvent) and not event.is_definition:
+ selections["lfo"] = None if event.is_off else event.slot
+ elif isinstance(event, EnvelopeEvent):
+ selections["env"] = None if event.slot == 0 else event.slot
+ elif isinstance(event, VibratoEvent):
+ selections["vib"] = None if event.slot == 0 else event.slot
+ elif isinstance(event, GlideEvent):
+ selections["glide"] = None if event.slot == 0 else event.slot
+ vol_values = np.full(len(indices), float(volume))
+ duty_values = np.full(len(indices), int(duty))
+ cents = np.full(len(indices), float(detune))
+ for i, tick in enumerate(local_ticks):
+ if selections["vol"] is not None:
+ vol_values[i] = _sequence_value(
+ _definition(
+ definitions.get("volume_envelopes", {}), selections["vol"]
+ ),
+ tick,
+ volume,
+ )
+ if selections["duty"] is not None:
+ duty_values[i] = int(
+ _sequence_value(
+ _definition(
+ definitions.get("duty_envelopes", {}), selections["duty"]
+ ),
+ tick,
+ duty,
+ )
+ )
+ cents[i] += 100.0 * _sequence_value(
+ _definition(definitions.get("note_envelopes", {}), selections["note"]),
+ tick,
+ 0,
+ )
+ cents[i] += _sequence_value(
+ _definition(
+ definitions.get("pitch_envelopes", {}), selections["pitch"]
+ ),
+ tick,
+ 0,
+ )
+ lfo = _definition(definitions.get("lfos", {}), selections["lfo"])
+ if lfo:
+ cents[i] += _triangle_lfo(
+ tick, lfo["delay"], lfo["speed"], lfo["depth"]
+ )
+ vib = _definition(definitions.get("vibratos", {}), selections["vib"])
+ if vib:
+ cents[i] += _triangle_lfo(
+ tick,
+ vib.get("delay_ticks", 0),
+ vib.get("period_ticks", 0),
+ vib.get("depth_cents", 0),
+ )
+ gli = _definition(definitions.get("glides", {}), selections["glide"])
+ if gli:
+ duration = gli.get("duration_ticks", 0)
+ cents[i] += (
+ gli.get("initial_offset_cents", 0) * max(0.0, 1.0 - tick / duration)
+ if duration
+ else 0
+ )
+ env = _definition(definitions.get("envelopes", {}), selections["env"])
+ if env:
+ vol_values[i] = _pyxel_envelope(env.get("points", []), tick, volume)
if self.channel_type == "noise":
- self._render_noise(start, end, event, velocity, mode)
- return
- if self.channel_type == "triangle":
- self._render_triangle(start, end, event, velocity, mode)
- return
- self._render_pulse(start, end, event, velocity, duty)
-
- def _render_pulse(
- self,
- start: int,
- end: int,
- event: NoteEvent,
- velocity: int,
- duty: int,
- ) -> None:
- freq = midi_to_freq(event.note_number)
- if freq <= 0:
- return
- phase_inc = freq / self.sample_rate
- duty_cycle = {0: 0.125, 1: 0.25, 2: 0.5, 3: 0.75}.get(duty, 0.5)
- samples = np.arange(end - start)
- phase = (samples * phase_inc) % 1.0
- waveform = np.where(phase < duty_cycle, 1.0, -1.0)
- amp = velocity / 15.0
- gate = max(0.0, min(1.0, event.gate_time))
- if gate < 1.0:
- gate_sample = int((end - start) * gate)
- if gate_sample < len(waveform):
- waveform[gate_sample:] = 0.0
- self.wave[start:end] += waveform * amp
-
- def _render_triangle(
- self,
- start: int,
- end: int,
- event: NoteEvent,
- velocity: int,
- mode: str,
- ) -> None:
- freq = midi_to_freq(event.note_number)
- if freq <= 0:
- return
- phase_inc = freq / self.sample_rate
- samples = np.arange(end - start)
- phase = (samples * phase_inc) % 1.0
- # 32-step triangle approximated by abs(phase*2-1)
- waveform = 2.0 * np.abs(phase * 2.0 - 1.0) - 1.0
- if mode == "pyxel":
- amp = velocity / 15.0
- else:
- amp = 1.0
- gate = max(0.0, min(1.0, event.gate_time))
- if gate < 1.0:
- gate_sample = int((end - start) * gate)
- if gate_sample < len(waveform):
- waveform[gate_sample:] = 0.0
- self.wave[start:end] += waveform * amp
-
- def _render_noise(
- self,
- start: int,
- end: int,
- event: NoteEvent,
- velocity: int,
- mode: str,
- ) -> None:
- if mode == "pyxel":
- period = max(0, min(15, 15 - floor(event.note_number / 8)))
- mode_flag = 1 if period >= 13 else 0
+ self._render_noise(indices, vol_values, note)
+ elif self.channel_type == "triangle":
+ self._render_triangle(indices, note, cents)
else:
- period = 8
- mode_flag = 0
- rate = noise_period_to_rate(period, mode_flag, self.sample_rate)
- phase_inc = rate / self.sample_rate
- samples = np.arange(end - start)
- phase = (samples * phase_inc) % 1.0
- diff = np.diff(phase, prepend=0)
- transitions = diff < 0
- # LFSR-like pseudo-random toggling on each period rollover
- state = np.zeros(len(samples), dtype=np.int32)
- lfsr = 1
- for i in range(len(samples)):
- if transitions[i]:
- # feedback bit
- if mode_flag:
- bit = (lfsr ^ (lfsr >> 6)) & 1
- lfsr = ((lfsr >> 1) | (bit << 14)) & 0x7FFF
- else:
- bit = (lfsr ^ (lfsr >> 1)) & 1
- lfsr = ((lfsr >> 1) | (bit << 14)) & 0x7FFF
- state[i] = lfsr & 1
- waveform = np.where(state, 1.0, -1.0)
- amp = velocity / 15.0
- gate = max(0.0, min(1.0, event.gate_time))
- if gate < 1.0:
- gate_sample = int((end - start) * gate)
- if gate_sample < len(waveform):
- waveform[gate_sample:] = 0.0
- self.wave[start:end] += waveform * amp
+ self._render_pulse(indices, note, cents, vol_values, duty_values, sweep)
+ def _render_pulse(self, indices, note, cents, volumes, duties, sweep):
+ phase = 0.0
+ sweep_elapsed = 0.0
+ timer = frequency_to_timer(
+ midi_to_freq(note.note_number + cents[0] / 100), "pulse"
+ )
+ for j, output_index in enumerate(indices):
+ if sweep and sweep.speed > 0 and sweep.depth != 0:
+ sweep_elapsed += 1 / self.sample_rate
+ interval = (sweep.speed + 1) / 120.0
+ if sweep_elapsed >= interval:
+ shift = abs(sweep.depth)
+ delta = timer >> shift
+ if sweep.depth < 0:
+ timer -= delta + (1 if self.channel_name == "Pulse1" else 0)
+ else:
+ timer += delta
+ sweep_elapsed -= interval
+ else:
+ timer = frequency_to_timer(
+ midi_to_freq(note.note_number + cents[j] / 100), "pulse"
+ )
+ target = (
+ timer + (timer >> abs(sweep.depth))
+ if sweep and sweep.depth > 0
+ else timer
+ )
+ if timer < 8 or target > 0x7FF or timer > 0x7FF:
+ self.wave[output_index] = 0
+ continue
+ phase = (
+ phase + timer_to_frequency(timer, "pulse") * 8 / self.sample_rate
+ ) % 8
+ self.wave[output_index] = PULSE_SEQUENCES[int(duties[j]) & 3][
+ int(phase)
+ ] * max(0, min(15, volumes[j]))
+ gate_index = int(len(indices) * max(0, min(1, note.gate_time)))
+ self.wave[indices[gate_index:]] = 0
-def tick_to_second(tick: int, bpm: int, ticks_per_quarter: int = 192) -> float:
- quarter_seconds = 60.0 / bpm
- return tick * (quarter_seconds / ticks_per_quarter)
+ def _render_triangle(self, indices, note, cents):
+ phase = self._triangle_phase
+ for j, output_index in enumerate(indices):
+ timer = frequency_to_timer(
+ midi_to_freq(note.note_number + cents[j] / 100), "triangle"
+ )
+ phase = (
+ phase + timer_to_frequency(timer, "triangle") * 32 / self.sample_rate
+ ) % 32
+ self.wave[output_index] = TRIANGLE_SEQUENCE[int(phase)]
+ self._triangle_phase = phase
+ # The 2A03 holds the current DAC value when gated, rather than forcing zero.
+ gate_index = int(len(indices) * max(0, min(1, note.gate_time)))
+ if 0 < gate_index < len(indices):
+ self.wave[indices[gate_index:]] = self.wave[indices[gate_index - 1]]
+
+ def _render_noise(self, indices, volumes, note):
+ period_index = (
+ 8 if note.note_number == 0 else max(0, min(15, 15 - note.note_number // 8))
+ )
+ cycles_per_sample = CPU_CLOCK / (NOISE_PERIODS[period_index] * self.sample_rate)
+ accumulator = self._noise_accumulator
+ lfsr = self._noise_lfsr
+ for j, output_index in enumerate(indices):
+ accumulator += cycles_per_sample
+ for _ in range(int(accumulator)):
+ tap = 6 if self.noise_mode else 1
+ feedback = (lfsr & 1) ^ ((lfsr >> tap) & 1)
+ lfsr = (lfsr >> 1) | (feedback << 14)
+ accumulator %= 1.0
+ self.wave[output_index] = 0 if lfsr & 1 else max(0, min(15, volumes[j]))
+ self._noise_accumulator = accumulator
+ self._noise_lfsr = lfsr
+ gate_index = int(len(indices) * max(0, min(1, note.gate_time)))
+ self.wave[indices[gate_index:]] = 0
def synthesize(
@@ -231,23 +496,35 @@ def synthesize(
normalize: bool = True,
) -> tuple[np.ndarray, float, list[ErrorDetail]]:
errors: list[ErrorDetail] = []
- total_ticks = max(ch.total_ticks for ch in note_sequence.channels.values())
+ total_ticks = max(
+ (ch.total_ticks for ch in note_sequence.channels.values()), default=0
+ )
if total_ticks <= 0:
return np.zeros(0, dtype=np.float64), 0.0, errors
- channels = []
- for _name, ch in note_sequence.channels.items():
- synth = ChannelSynthesizer(ch.channel_type, sample_rate)
- wave = synth.render(ch.events, total_ticks, note_sequence.bpm, mode)
- channels.append(wave)
- max_len = max(len(w) for w in channels)
- padded = [np.pad(w, (0, max_len - len(w)), mode="constant") for w in channels]
- mixed = np.mean(padded, axis=0)
- peak = np.max(np.abs(mixed))
- if normalize and peak > 0:
- mixed = mixed / peak
- mixed = np.clip(mixed, -1.0, 1.0)
- duration = max_len / sample_rate
- return mixed, duration, errors
+ tempo_map = build_tempo_map(note_sequence)
+ rendered: dict[str, np.ndarray] = {}
+ for name, channel in note_sequence.channels.items():
+ rendered[name] = ChannelSynthesizer(
+ channel.channel_type, sample_rate, name
+ ).render(
+ channel.events,
+ total_ticks,
+ note_sequence.bpm,
+ mode,
+ note_sequence.definitions,
+ tempo_map,
+ )
+ zero = np.zeros(max(map(len, rendered.values())), dtype=np.float64)
+ mixed = apu_mix(
+ rendered.get("Pulse1", zero),
+ rendered.get("Pulse2", zero),
+ rendered.get("Triangle", zero),
+ rendered.get("Noise", zero),
+ )
+ if normalize and mixed.size and np.max(np.abs(mixed)) > 0:
+ mixed /= np.max(np.abs(mixed))
+ mixed = np.clip(mixed, -1, 1)
+ return mixed, len(mixed) / sample_rate, errors
def write_wav(path: Path, wave_data: np.ndarray, sample_rate: int) -> list[ErrorDetail]:
@@ -269,29 +546,37 @@ def write_wav(path: Path, wave_data: np.ndarray, sample_rate: int) -> list[Error
column=0,
message=f"WAVファイルの出力に失敗しました: {exc}",
severity="error",
- hint="しばらく待ってから再度お試しください。",
+ hint="出力先を確認してください。",
)
)
return errors
def build_channel_summary(note_sequence: NoteSequence | dict) -> list[dict]:
- if isinstance(note_sequence, dict):
- channels = note_sequence["channels"]
- else:
- channels = note_sequence.channels
+ channels = (
+ note_sequence["channels"]
+ if isinstance(note_sequence, dict)
+ else note_sequence.channels
+ )
summary = []
- for name, ch in channels.items():
- events = ch["events"] if isinstance(ch, dict) else ch.events
- total_ticks = ch["total_ticks"] if isinstance(ch, dict) else ch.total_ticks
- notes = [e for e in events if isinstance(e, NoteEvent)]
- if not notes:
- notes = [
- e for e in events if isinstance(e, dict) and e.get("type") == "note"
- ]
- octaves = [e["note_number"] // 12 - 1 for e in notes]
- else:
- octaves = [e.note_number // 12 - 1 for e in notes]
+ for name, channel in channels.items():
+ events = channel["events"] if isinstance(channel, dict) else channel.events
+ total_ticks = (
+ channel["total_ticks"] if isinstance(channel, dict) else channel.total_ticks
+ )
+ notes = [
+ e
+ for e in events
+ if (
+ isinstance(e, NoteEvent)
+ or isinstance(e, dict)
+ and e.get("type") == "note"
+ )
+ ]
+ numbers = [
+ e["note_number"] if isinstance(e, dict) else e.note_number for e in notes
+ ]
+ octaves = [n // 12 - 1 for n in numbers]
summary.append(
{
"channel": name,
diff --git a/tests/test_mml_conformance.py b/tests/test_mml_conformance.py
index f611f6e..442be81 100644
--- a/tests/test_mml_conformance.py
+++ b/tests/test_mml_conformance.py
@@ -135,7 +135,7 @@ def test_empty_and_unicode_inputs_do_not_crash_pipeline(mode):
assert ErrorCode.SYNTAX_INVALID_TOKEN in error_codes(errors)
-def test_validate_full_pyxel_path_preserves_warning_and_summary():
+def test_validate_full_pyxel_path_supports_extension_and_summary():
result = compose_mml(
action="validate",
mode="pyxel",
@@ -143,32 +143,43 @@ def test_validate_full_pyxel_path_preserves_warning_and_summary():
)
assert result["valid"] is True
assert result["channel_summary"]
- assert any(
- warning["code"] == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE.value
- for warning in result["warnings"]
- )
+ assert not result["warnings"]
@pytest.mark.parametrize(
- ("source", "expected_warning_code"),
+ "source",
[
- ("A D10 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
- ("A s1,2 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
- ("A v+5 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
- ("A @v0 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
- ("A @@0 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
- ("A MP1 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
- ("A MPOF c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
- ("A EP1 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
- ("A EPOF c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
- ("A EN1 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
- ("A ENOF c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE),
+ "A D10 c",
+ "A s1,2 c",
+ "A v+5 c",
+ "A @v0={15|10} @v0 c",
+ "A @0={0|1} @@0 c",
+ "A @MP1={0,10,5,0} MP1 c",
+ "A MPOF c",
+ "A @EP1={0|5} EP1 c",
+ "A EPOF c",
+ "A @EN1={0|1} EN1 c",
+ "A ENOF c",
],
)
-def test_ppmck_new_commands_produce_warning(source, expected_warning_code):
+def test_ppmck_extension_commands_are_supported(source):
_, _, _, errors = parse_pipeline(source, "ppmck")
assert not any(e.severity == "error" for e in errors)
- assert expected_warning_code in error_codes(errors)
+ assert ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE not in error_codes(errors)
+
+
+def test_ppmck_definition_is_allowed_before_track():
+ _, _, sequence, errors = parse_pipeline("@v1={15|8}\nA @v1 c", "ppmck")
+ assert not errors
+ assert sequence.definitions["volume_envelopes"][1] == {
+ "points": [15],
+ "loop_points": [8],
+ }
+
+
+def test_ppmck_duplicate_definition_is_rejected():
+ _, _, _, errors = parse_pipeline("@v1={15}\n@v1={8}\nA c", "ppmck")
+ assert ErrorCode.SEMANTIC_DUPLICATE_DEFINITION in error_codes(errors)
def test_ppmck_at_duty_in_pulse_channel():
diff --git a/tests/test_parser_detailed.py b/tests/test_parser_detailed.py
index f1f4b6a..5026058 100644
--- a/tests/test_parser_detailed.py
+++ b/tests/test_parser_detailed.py
@@ -77,46 +77,53 @@ def test_ppmck_tie_cmd():
assert notes[0].duration == 384
-def test_ppmck_detune_warning():
+def test_ppmck_detune_supported():
source = "A t120 l4 o4 D10\n c"
ns, errors = _analyze_ppmck(source)
- assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors)
+ assert not errors
+ assert (
+ next(
+ e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)
+ ).detune_cents
+ == 10
+ )
-def test_ppmck_sweep_warning():
+def test_ppmck_sweep_supported():
source = "A t120 l4 o4 s1,2\n c"
ns, errors = _analyze_ppmck(source)
- assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors)
+ assert not errors
-def test_ppmck_rel_vol_warning():
+def test_ppmck_rel_vol_supported():
source = "A t120 l4 o4 v+5\n c"
ns, errors = _analyze_ppmck(source)
- assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors)
+ assert not errors
-def test_ppmck_vol_env_use_warning():
- source = "A t120 l4 o4 @v0\n c"
+def test_ppmck_vol_env_use_supported():
+ source = "A t120 l4 o4 @v0={15|10} @v0\n c"
ns, errors = _analyze_ppmck(source)
- assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors)
+ assert not errors
-def test_ppmck_vol_env_def_warning():
+def test_ppmck_vol_env_def_supported():
source = "A t120 l4 o4\n@v0 = { 15, 10, | 5 }\n c"
ns, errors = _analyze_ppmck(source)
- assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors)
+ assert not errors
+ assert ns.definitions["volume_envelopes"][0]["points"] == [15, 10]
-def test_ppmck_lfo_def_warning():
+def test_ppmck_lfo_def_supported():
source = "A t120 l4 o4\n@MP0 = { 0, 10, 5, 0 }\n c"
ns, errors = _analyze_ppmck(source)
- assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors)
+ assert not errors
-def test_ppmck_duty_env_use_warning():
- source = "A t120 l4 o4 @@0\n c"
+def test_ppmck_duty_env_use_supported():
+ source = "A t120 l4 o4 @0={0|1} @@0\n c"
ns, errors = _analyze_ppmck(source)
- assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors)
+ assert not errors
def test_ppmck_duty_triangle_error():
diff --git a/tests/test_synthesizer.py b/tests/test_synthesizer.py
index c3e110e..3d7b61e 100644
--- a/tests/test_synthesizer.py
+++ b/tests/test_synthesizer.py
@@ -8,15 +8,25 @@ import pytest
from mml_composemusic_mcp.ir import (
NoteEvent,
NoteSequence,
+ TempoEvent,
+ VolumeEnvelopeEvent,
VolumeEvent,
)
from mml_composemusic_mcp.synthesizer import (
+ NOISE_PERIODS,
+ PULSE_SEQUENCES,
+ TRIANGLE_SEQUENCE,
ChannelSynthesizer,
+ TempoMap,
+ apu_mix,
build_channel_summary,
+ build_tempo_map,
+ frequency_to_timer,
midi_to_freq,
noise_period_to_rate,
synthesize,
tick_to_second,
+ timer_to_frequency,
write_wav,
)
@@ -40,6 +50,61 @@ def test_noise_period_to_rate():
assert rate > 0
+def test_ntsc_noise_period_table():
+ assert NOISE_PERIODS == (
+ 4,
+ 8,
+ 16,
+ 32,
+ 64,
+ 96,
+ 128,
+ 160,
+ 202,
+ 254,
+ 380,
+ 508,
+ 762,
+ 1016,
+ 2034,
+ 4068,
+ )
+
+
+def test_apu_step_sequences():
+ assert [sum(sequence) for sequence in PULSE_SEQUENCES] == [1, 2, 4, 6]
+ assert TRIANGLE_SEQUENCE[:16] == tuple(range(15, -1, -1))
+ assert TRIANGLE_SEQUENCE[16:] == tuple(range(16))
+
+
+def test_timer_quantization_matches_2a03_formula():
+ timer = frequency_to_timer(440.0, "pulse")
+ assert timer == round(1_789_772.5 / (16 * 440.0) - 1)
+ assert timer_to_frequency(timer, "pulse") == pytest.approx(440.0, rel=0.003)
+
+
+def test_nonlinear_apu_mixer():
+ one = np.array([15.0])
+ zero = np.array([0.0])
+ single = apu_mix(one, zero, zero, zero)[0]
+ double = apu_mix(one, one, zero, zero)[0]
+ assert 0 < single < double < single * 2
+
+
+def test_tempo_map_integrates_mid_song_change():
+ tempo = TempoMap([(0, 120), (192, 60)])
+ assert tempo.tick_to_second(192) == pytest.approx(0.5)
+ assert tempo.tick_to_second(384) == pytest.approx(1.5)
+
+
+def test_note_sequence_tempo_map_keeps_default_before_late_change():
+ ns = NoteSequence(bpm=120)
+ ns.channels["Pulse1"].events.append(TempoEvent(tick_position=192, bpm=60))
+ tempo = build_tempo_map(ns)
+ assert tempo.tick_to_second(192) == pytest.approx(0.5)
+ assert tempo.tick_to_second(384) == pytest.approx(1.5)
+
+
def test_tick_to_second():
assert tick_to_second(192, 120) == pytest.approx(0.5, rel=1e-6)
@@ -65,9 +130,9 @@ def test_triangle_render_ppmck():
wave_out = synth.render(events, 192, 120, "ppmck")
assert len(wave_out) > 0
assert np.any(wave_out != 0)
- # In ppmck mode, triangle amplitude should be full (velocity ignored)
+ # Raw channel output is the 2A03's four-bit DAC value.
peak = np.max(np.abs(wave_out))
- assert peak == pytest.approx(1.0, rel=1e-2)
+ assert peak == 15
def test_triangle_render_pyxel():
@@ -79,9 +144,9 @@ def test_triangle_render_pyxel():
wave_out = synth.render(events, 192, 120, "pyxel")
assert len(wave_out) > 0
assert np.any(wave_out != 0)
- # In pyxel mode, velocity/15 scales amplitude
+ # Triangle volume is not controlled by the 2A03 DAC.
peak = np.max(np.abs(wave_out))
- assert peak == pytest.approx(7 / 15, rel=1e-1)
+ assert peak == 15
def test_noise_render_ppmck():
@@ -100,6 +165,17 @@ def test_noise_render_pyxel():
assert np.any(wave_out != 0)
+def test_noise_short_mode_uses_different_lfsr_tap():
+ events = [NoteEvent(tick_position=0, duration=192, note_number=48, velocity=15)]
+ long_wave = ChannelSynthesizer("noise", 8000, noise_mode=0).render(
+ events, 192, 120, "pyxel"
+ )
+ short_wave = ChannelSynthesizer("noise", 8000, noise_mode=1).render(
+ events, 192, 120, "pyxel"
+ )
+ assert not np.array_equal(long_wave, short_wave)
+
+
# --- Full synthesize ---
@@ -134,6 +210,20 @@ def test_synthesize_normalize():
assert np.max(np.abs(on_data)) == pytest.approx(1.0, rel=1e-2)
+def test_volume_envelope_changes_apu_dac_at_48_ticks():
+ definitions = {"volume_envelopes": {1: {"points": [15, 0], "loop_points": []}}}
+ events = [
+ VolumeEnvelopeEvent(tick_position=0, slot=1),
+ NoteEvent(tick_position=0, duration=96, note_number=69, velocity=15),
+ ]
+ wave_out = ChannelSynthesizer("pulse", 8000).render(
+ events, 96, 120, "ppmck", definitions
+ )
+ midpoint = len(wave_out) // 2
+ assert np.max(wave_out[:midpoint]) == 15
+ assert np.max(wave_out[midpoint:]) == 0
+
+
# --- WAV output ---