From 5dcb3386e8d319a8f43101e9973d5dc8b7af9f36 Mon Sep 17 00:00:00 2001 From: yousan Date: Sun, 12 Apr 2026 22:01:06 +0900 Subject: [PATCH 01/21] =?UTF-8?q?docs:=20Misaki=E4=BA=92=E6=8F=9B=E4=B8=AD?= =?UTF-8?q?=E5=9B=BD=E8=AA=9EG2P=20=E8=A8=AD=E8=A8=88=E3=83=89=E3=82=AD?= =?UTF-8?q?=E3=83=A5=E3=83=A1=E3=83=B3=E3=83=88=E3=81=A8=E3=83=9E=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E3=82=B9=E3=83=88=E3=83=BC=E3=83=B3=E3=83=BB=E3=83=81?= =?UTF-8?q?=E3=82=B1=E3=83=83=E3=83=88=E7=B4=A2=E5=BC=95=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #56 対応のため、Kokoro TTS フロントエンド Misaki との互換出力モードを DotNetG2P.Chinese に追加する計画を策定。設計ドキュメント、マイルストーン計画 (Mi1-Mi3)、チケット索引を docs/ 配下に作成した。 関連: #56 --- docs/guides/misaki-compatible-chinese.md | 196 +++++++++++++++++++++++ docs/guides/misaki-milestones.md | 150 +++++++++++++++++ docs/guides/toc.yml | 5 +- docs/tickets/README.md | 45 ++++++ 4 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 docs/guides/misaki-compatible-chinese.md create mode 100644 docs/guides/misaki-milestones.md create mode 100644 docs/tickets/README.md diff --git a/docs/guides/misaki-compatible-chinese.md b/docs/guides/misaki-compatible-chinese.md new file mode 100644 index 0000000..c842da7 --- /dev/null +++ b/docs/guides/misaki-compatible-chinese.md @@ -0,0 +1,196 @@ +# Misaki互換 中国語G2P出力モード 設計ドキュメント + +> 対応Issue: [#56 - How can i make result similar like misaki does?](https://github.com/ayutaz/dot-net-g2p/issues/56) + +## 背景 + +[Kokoro TTS](https://github.com/hexgrad/kokoro) (82Mパラメータ) はG2Pフロントエンドとして [Misaki](https://github.com/hexgrad/misaki) を使用する。C#によるKokoro推論エンジン ([KokoroSharp](https://github.com/Lyrcaxis/KokoroSharp)) が存在するが、MisakiのC#ポートが無いため中国語G2P品質が低い (eSpeak-ng依存、KokoroSharp Issue#5)。 + +DotNetG2P.ChineseにMisaki互換出力モードを追加することで、C#/UnityのKokoro TTSエコシステムで採用可能になる。 + +## 現状の差異 + +`"你好"` の変換結果: + +| 項目 | Misaki (Legacy) | DotNetG2P 現行 | +|------|----------------|----------------| +| 出力例 | `ni↓xau̯↓` | `ni˧˥ xaʊ˨˩˦` | +| 声調記号 | 矢印 (`→` `↗` `↓` `↘`) | IPA tone letters (`˥˥` `˧˥` `˨˩˦` `˥˩`) | +| 音節区切り | スペース (語間) | スペース (音節間) | +| 声母 j/q | `ʨ` / `ʨʰ` | `tɕ` / `tɕʰ` | +| 二重母音 | `ai̯` `au̯` `ei̯` `ou̯` (非音節化符号) | `aɪ` `aʊ` `eɪ` `oʊ` (別字母) | +| zh/ch/sh+i | `ɻ̩` / `ʐ̩` | `ɻ̩` | +| z/c/s+i | `ɹ̩` / `z̩` | `ɹ̩` | + +### 声調マッピング詳細 + +| 声調 | DotNetG2P (Chao式) | Misaki (矢印) | +|------|-------------------|--------------| +| 1声 (陰平) | `˥˥` | `→` | +| 2声 (陽平) | `˧˥` | `↗` | +| 3声 (上声) | `˨˩˦` | `↓` | +| 4声 (去声) | `˥˩` | `↘` | +| 軽声 | なし | なし | + +### 声母マッピング差異 + +| ピンイン | DotNetG2P | Misaki | +|---------|-----------|--------| +| j | `tɕ` | `ʨ` | +| q | `tɕʰ` | `ʨʰ` | +| x | `ɕ` | `ɕ` (同一) | + +### 韻母 (二重母音) マッピング差異 + +| 韻母 | DotNetG2P | Misaki | +|------|-----------|--------| +| ai | `aɪ` (U+026A) | `ai̯` (i + U+032F) | +| ei | `eɪ` | `ei̯` | +| ao | `aʊ` (U+028A) | `au̯` (u + U+032F) | +| ou | `oʊ` | `ou̯` | +| uai | `uaɪ` | `uai̯` | +| ui | `ueɪ` | `uei̯` | +| iu | `ioʊ` | `iou̯` | + +## 実装方式 + +### 方式比較 + +| 方式 | 概要 | 判定 | +|------|------|------| +| A: PinyinStyle に追加 | PinyinStyle はピンイン表記用 enum。IPA 出力とはレイヤーが異なる | **不採用** (責務混在) | +| **B: PinyinToMisaki.cs 新規 + ToMisakiIpa()** | PiperIpa と同パターン。独立マッピングテーブル | **採用** | +| C: ToIPA() 出力のポストプロセス | 文字列置換で変換。脆弱で将来変更に弱い | **不採用** | + +### 採用: 方式B — 独立変換クラス + 専用メソッド + +既存の `PinyinToIpa` / `PinyinToPiperIpa` / `PinyinToZhuyin` と同じ「変換先ごとに独立クラス」パターンに従う。 + +``` +src/DotNetG2P.Chinese/Conversion/ +├── PinyinToIpa.cs ← 標準IPA (既存) +├── PinyinToPiperIpa.cs ← piper-plus互換 (既存) +├── PinyinToZhuyin.cs ← 注音符号 (既存) +└── PinyinToMisaki.cs ← Misaki互換 (新規) +``` + +**選定理由:** + +1. **一貫性**: 全変換クラスが独立マッピングテーブルを持つ既存設計に完全合致 +2. **拡張性**: `RunPipeline` の `Func converter` 委譲パターンにそのまま乗る +3. **保守性**: Misaki の仕様変更時にテーブル差分のみの修正で対応可能 +4. **独立性**: 既存の ToIPA / ToPiperIPA 出力に一切影響しない + +## 変更ファイル一覧 + +### 新規作成 + +| ファイル | 内容 | +|---------|------| +| `src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs` | Misaki互換マッピングテーブル (声母/韻母/声調) | +| `tests/DotNetG2P.Tests/ChineseG2P/ChineseMisakiIpaTests.cs` | Misaki互換出力テスト | + +### 変更 + +| ファイル | 内容 | +|---------|------| +| `src/DotNetG2P.Chinese/ChineseG2PEngine.cs` | `ToMisakiIpa()` / `ToMisakiIpaBatch()` 公開メソッド追加 | + +### 変更不要 (共通基盤) + +- `PinyinParser.cs` — ピンイン解析 (共通) +- `ToneConverter.cs` — 声調変換 (共通) +- `ToneSandhiProcessor.cs` — 声調変調 (共通、結果はそのまま反映) +- `ChineseG2POptions.cs` — オプション (Separator等は既存で対応可能) +- `DotNetG2P.Multilingual/` — 初期対応では変更不要 + +## PinyinToMisaki.cs 設計 + +### 声母マッピング + +```csharp +private static readonly Dictionary s_initialIpa = new() +{ + [Initial.B] = "p", + [Initial.P] = "pʰ", + [Initial.M] = "m", + [Initial.F] = "f", + [Initial.D] = "t", + [Initial.T] = "tʰ", + [Initial.N] = "n", + [Initial.L] = "l", + [Initial.G] = "k", + [Initial.K] = "kʰ", + [Initial.H] = "x", + [Initial.J] = "ʨ", // DotNetG2P: tɕ → Misaki: ʨ + [Initial.Q] = "ʨʰ", // DotNetG2P: tɕʰ → Misaki: ʨʰ + [Initial.X] = "ɕ", + [Initial.Zh] = "ʈʂ", + [Initial.Ch] = "ʈʂʰ", + [Initial.Sh] = "ʂ", + [Initial.R] = "ɻ", + [Initial.Z] = "ʦ", // DotNetG2P: ts → Misaki: ʦ + [Initial.C] = "ʦʰ", // DotNetG2P: tsʰ → Misaki: ʦʰ + [Initial.S] = "s", + [Initial.Y] = "j", + [Initial.W] = "w", +}; +``` + +### 韻母マッピング (二重母音の差異) + +```csharp +// 主な差異: ɪ→i̯, ʊ→u̯ (非音節化符号 U+032F 付き) +[Final.Ai] = "ai\u032F", // aɪ → ai̯ +[Final.Ei] = "ei\u032F", // eɪ → ei̯ +[Final.Ao] = "au\u032F", // aʊ → au̯ +[Final.Ou] = "ou\u032F", // oʊ → ou̯ +[Final.Iao] = "iau\u032F", // iaʊ → iau̯ +[Final.Iu] = "iou\u032F", // ioʊ → iou̯ +[Final.Uai] = "uai\u032F", // uaɪ → uai̯ +[Final.Ui] = "uei\u032F", // ueɪ → uei̯ +``` + +### 声調マッピング + +```csharp +private static readonly string[] s_toneArrows = new[] +{ + "", // Neutral (0) - なし + "→", // First (1) + "↗", // Second (2) + "↓", // Third (3) + "↘", // Fourth (4) +}; +``` + +## ChineseG2PEngine 公開API + +既存の `ToPiperIPA` パターンに準拠: + +```csharp +// 文字列出力 +public string ToMisakiIpa(string text) +public string ToMisakiIpa(string text, bool includeTones) + +// バッチ出力 +public string[] ToMisakiIpaBatch(string[] texts) +public string[] ToMisakiIpaBatch(string[] texts, bool includeTones) +``` + +## テスト方針 + +`ChineseMisakiIpaTests.cs` で以下をカバー: + +1. **声調マッピング**: 各声調 (1-4 + 軽声) が正しい矢印記号に変換されること +2. **声母マッピング**: j/q → ʨ/ʨʰ、z/c → ʦ/ʦʰ 等の差異が反映されること +3. **韻母マッピング**: 二重母音の非音節化符号が正しいこと +4. **声調変調**: ToneSandhi の結果が Misaki 出力にも反映されること +5. **エッジケース**: 軽声、er化、句読点、空文字列等 +6. **Misaki 出力例との比較**: issue #56 の `ni↓xau̯↓` 等 + +## 備考 + +- Misaki には Legacy パス (IPA+矢印) と v1.1 パス (注音符号) の2つが存在するが、Kokoro-82M で使用されるのは Legacy パスのみ。本対応は Legacy パスを対象とする +- Misaki が `ꭧ` (U+AB67) を zh/ch の子音IPAに使用する件は、Kokoro vocab に含まれない可能性があるため初期対応では見送り、必要に応じて追加する +- Multilingual 層への統合は将来の追加作業とする diff --git a/docs/guides/misaki-milestones.md b/docs/guides/misaki-milestones.md new file mode 100644 index 0000000..68a1ea9 --- /dev/null +++ b/docs/guides/misaki-milestones.md @@ -0,0 +1,150 @@ +# Misaki互換 中国語G2P — マイルストーン計画 + +> 対応Issue: [#56](https://github.com/ayutaz/dot-net-g2p/issues/56) | 設計ドキュメント: [misaki-compatible-chinese.md](misaki-compatible-chinese.md) + +## 概要 + +DotNetG2P.Chinese に Misaki 互換出力モードを追加する。全3マイルストーン。 + +## チケット一覧 + +詳細は [../tickets/README.md](../tickets/README.md) を参照。 + +| マイルストーン | チケット | +|-------------|---------| +| Mi1 | [T01 マッピングテーブル](../tickets/T01-misaki-mapping-tables.md), [T02 Convert統合](../tickets/T02-misaki-convert-method.md) | +| Mi2 | [T03 API追加](../tickets/T03-engine-api-integration.md), [T04 テスト実装](../tickets/T04-misaki-tests.md) | +| Mi3 | [T05 ドキュメント・QA](../tickets/T05-documentation-qa.md), [T06 リリース準備](../tickets/T06-release-followup.md) | + +--- + +## Mi1: PinyinToMisaki 変換クラス + +**目標**: Misaki互換のマッピングテーブルを持つ変換クラスを新規作成する。 + +### 成果物 + +| ファイル | 内容 | +|---------|------| +| `src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs` | 新規作成 | + +### 実装内容 + +- [x] `PinyinToMisaki` 内部静的クラスの作成(`PinyinToPiperIpa` と同構造) +- [x] 声母マッピングテーブル `s_initialIpa` (22エントリ) + - `j/q` → `ʨ/ʨʰ` (DotNetG2Pの `tɕ/tɕʰ` から変更) + - `z/c` → `ʦ/ʦʰ` (DotNetG2Pの `ts/tsʰ` から変更) + - 他は標準IPA と同一 +- [x] 韻母マッピングテーブル `s_finalIpa` (32エントリ) + - 二重母音: `ɪ` → `i̯` (i + U+032F)、`ʊ` → `u̯` (u + U+032F) + - 例: `aɪ` → `ai̯`、`aʊ` → `au̯`、`eɪ` → `ei̯`、`oʊ` → `ou̯` +- [x] 声調マッピング `s_toneArrows` (5エントリ) + - 1声 → `→`、2声 → `↗`、3声 → `↓`、4声 → `↘`、軽声 → 空 +- [x] `Convert(string pinyin)` / `Convert(string pinyin, bool includeTones)` メソッド +- [x] `ConvertSyllable(PinyinSyllable syllable, bool includeTones)` 内部メソッド +- [x] そり舌母音 (zh/ch/sh/r + i) の処理 +- [x] 歯茎母音 (z/c/s + i) の処理 +- [x] `ShouldOmitSemivowel` (y/w 半母音省略判定) + +### 完了条件 + +- `PinyinToMisaki.Convert("mā")` → `"ma→"` が返ること +- `PinyinToMisaki.Convert("hǎo")` → `"xau̯↓"` が返ること +- `PinyinToMisaki.Convert("jī")` → `"ʨi→"` が返ること (声母差異) +- `PinyinToMisaki.Convert("māi")` → `"mai̯→"` が返ること (韻母差異) +- ビルドが通ること (`dotnet build`) + +--- + +## Mi2: ChineseG2PEngine API統合 + テスト + +**目標**: `ChineseG2PEngine` にMisaki互換の公開APIを追加し、テストで検証する。 + +### 成果物 + +| ファイル | 内容 | +|---------|------| +| `src/DotNetG2P.Chinese/ChineseG2PEngine.cs` | メソッド追加 | +| `tests/DotNetG2P.Tests/ChineseG2P/ChineseMisakiIpaTests.cs` | 新規作成 | + +### 実装内容 — API + +- [x] `ToMisakiIpa(string text)` — Misaki互換IPA文字列を返す +- [x] `ToMisakiIpa(string text, bool includeTones)` — 声調有無指定オーバーロード +- [x] `ToMisakiIpaBatch(string[] texts)` — バッチ変換 +- [x] `ToMisakiIpaBatch(string[] texts, bool includeTones)` — バッチ変換 (声調有無指定) +- [x] 内部実装: `RunPipeline(text, p => PinyinToMisaki.Convert(p, includeTones))` パターン + +### 実装内容 — テスト + +- [x] **声調テスト**: 各声調 (1-4 + 軽声) の矢印記号変換 + - 1声: `妈` → `ma→`、2声: `麻` → `ma↗`、3声: `马` → `ma↓`、4声: `骂` → `ma↘` +- [x] **声母テスト**: Misaki固有の声母マッピング + - `j/q` → `ʨ/ʨʰ`、`z/c` → `ʦ/ʦʰ` +- [x] **韻母テスト**: 二重母音の非音節化符号 + - `ai/ei/ao/ou` → `ai̯/ei̯/au̯/ou̯` +- [x] **声調変調テスト**: ToneSandhi結果がMisaki出力にも反映 + - 三声連読: `你好` → 3+3 → 2+3 → `ni↗xau̯↓` + - 「一」変調: `一个` → `i↘kɤ↘` (一 + 4声 → 2声に変調) +- [x] **エッジケーステスト** + - 空文字列 → 空文字列 + - 句読点のみ → 句読点そのまま + - 非漢字テキスト → パススルー + - 軽声 (声調なし) → 矢印なし + - er化音 +- [x] **Issue #56 再現テスト**: `"你好"` の出力がMisaki互換であること + +### 完了条件 + +- `dotnet test` で ChineseMisakiIpaTests 全件パス +- 既存テスト (936件) に回帰なし +- `engine.ToMisakiIpa("你好")` が Misaki と同等の出力を返すこと + +--- + +## Mi3: ドキュメント・品質保証・リリース準備 + +**目標**: ドキュメント整備、エッジケース追加テスト、リリース準備を行う。 + +### 成果物 + +| ファイル | 内容 | +|---------|------| +| `README.md` | Misaki互換出力の使用例を追加 | +| `CLAUDE.md` | 進捗状況テーブル更新 | +| `docs/guides/misaki-compatible-chinese.md` | 設計ドキュメント最終更新 | +| `tests/DotNetG2P.Tests/ChineseG2P/ChineseMisakiEdgeCaseTests.cs` | 追加エッジケーステスト (任意) | + +### 実装内容 + +- [x] **README.md 更新** + - 中国語セクションに Misaki 互換出力の使用例を追加 + - `ToMisakiIpa()` の API 説明 + - Kokoro TTS との連携例 +- [x] **CLAUDE.md 更新** + - 中国語パッケージの備考に「Misaki互換出力対応」を追記 +- [x] **品質保証** + - Misaki の Python 実装との出力比較テスト (可能な範囲で) + - パフォーマンステスト: `ToMisakiIpa` が `ToIPA` と同等の速度であること +- [x] **Issue #56 へのフォローアップコメント** + - 実装完了の報告 + - 使用例コード提示 +- [x] **設計ドキュメント最終更新** + - マイルストーン完了状況を反映 + - 備考・知見を追記 + +### 完了条件 + +- README に Misaki 互換出力の使用例があること +- `dotnet test` 全テストパス +- Issue #56 にフォローアップコメント投稿済み + +--- + +## マイルストーン進捗サマリ + +| マイルストーン | 内容 | 状態 | +|--------------|------|------| +| **Mi1** | PinyinToMisaki 変換クラス | 未着手 | +| **Mi2** | API統合 + テスト | 未着手 | +| **Mi3** | ドキュメント・品質保証・リリース準備 | 未着手 | diff --git a/docs/guides/toc.yml b/docs/guides/toc.yml index fe51488..1b7621b 100644 --- a/docs/guides/toc.yml +++ b/docs/guides/toc.yml @@ -1 +1,4 @@ -[] +- name: Misaki互換 中国語G2P出力モード + href: misaki-compatible-chinese.md +- name: Misaki互換 マイルストーン計画 + href: misaki-milestones.md diff --git a/docs/tickets/README.md b/docs/tickets/README.md new file mode 100644 index 0000000..35f8cdf --- /dev/null +++ b/docs/tickets/README.md @@ -0,0 +1,45 @@ +# Misaki互換 中国語G2P チケット一覧 + +> 対応Issue: [#56](https://github.com/ayutaz/dot-net-g2p/issues/56) +> マイルストーン計画: [../guides/misaki-milestones.md](../guides/misaki-milestones.md) +> 設計ドキュメント: [../guides/misaki-compatible-chinese.md](../guides/misaki-compatible-chinese.md) + +## フェーズ別チケット + +### Phase 1 — Mi1: PinyinToMisaki 変換クラス + +| ID | タイトル | 状態 | 依存 | 後続 | +|----|----------|------|------|------| +| [T01](T01-misaki-mapping-tables.md) | PinyinToMisaki マッピングテーブル設計・実装 | 未着手 | なし | T02 | +| [T02](T02-misaki-convert-method.md) | PinyinToMisaki Convert メソッド統合 | 未着手 | T01 | T03 | + +### Phase 2 — Mi2: API統合 + テスト + +| ID | タイトル | 状態 | 依存 | 後続 | +|----|----------|------|------|------| +| [T03](T03-engine-api-integration.md) | ChineseG2PEngine ToMisakiIpa API 追加 | 未着手 | T02 | T04 | +| [T04](T04-misaki-tests.md) | Misaki互換テスト実装 | 未着手 | T03 | T05 | + +### Phase 3 — Mi3: ドキュメント・品質保証・リリース準備 + +| ID | タイトル | 状態 | 依存 | 後続 | +|----|----------|------|------|------| +| [T05](T05-documentation-qa.md) | ドキュメント更新・品質保証 | 未着手 | T04 | T06 | +| [T06](T06-release-followup.md) | Issue#56 フォローアップ・リリース準備 | 未着手 | T05 | なし | + +## 依存関係グラフ + +``` +T01 ──► T02 ──► T03 ──► T04 ──► T05 ──► T06 + [Mi1] [Mi1] [Mi2] [Mi2] [Mi3] [Mi3] +``` + +## フェーズレビュー + +各フェーズ完了後、「一から作り直すとしたら」セクションをエージェントチームでレビュー・修正してから次フェーズに進む運用とする。 + +| フェーズ | レビュー対象チケット | レビュー状態 | +|---------|---------------------|-------------| +| Phase 1 | T01, T02 | 未実施 | +| Phase 2 | T03, T04 | 未実施 | +| Phase 3 | T05, T06 | 未実施 | From dcf7976c8032aab21f690726e3e27b04b5c1380b Mon Sep 17 00:00:00 2001 From: yousan Date: Sun, 12 Apr 2026 22:02:41 +0900 Subject: [PATCH 02/21] =?UTF-8?q?docs:=20T01=20PinyinToMisaki=20=E3=83=9E?= =?UTF-8?q?=E3=83=83=E3=83=94=E3=83=B3=E3=82=B0=E3=83=86=E3=83=BC=E3=83=96?= =?UTF-8?q?=E3=83=AB=E8=A8=AD=E8=A8=88=E3=83=BB=E5=AE=9F=E8=A3=85=20?= =?UTF-8?q?=E3=83=81=E3=82=B1=E3=83=83=E3=83=88=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mi1 フェーズ1枚目。声母22エントリ、韻母36エントリ、声調5エントリの Misaki互換マッピングテーブルを新規作成するタスク。PinyinToIpa.cs との 差異(j/q→ʨ/ʨʰ, z/c→ʦ/ʦʰ, 二重母音の非音節化符号, 声調矢印)を 全エントリで明示した。 関連: #56 --- docs/tickets/T01-misaki-mapping-tables.md | 407 ++++++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 docs/tickets/T01-misaki-mapping-tables.md diff --git a/docs/tickets/T01-misaki-mapping-tables.md b/docs/tickets/T01-misaki-mapping-tables.md new file mode 100644 index 0000000..7a529aa --- /dev/null +++ b/docs/tickets/T01-misaki-mapping-tables.md @@ -0,0 +1,407 @@ +--- +ticket: T01 +title: PinyinToMisaki マッピングテーブル設計・実装 +milestone: Mi1 +status: 未着手 +depends_on: [] +blocks: [T02] +--- + +# T01: PinyinToMisaki マッピングテーブル設計・実装 + +## 1. タスク目的とゴール + +### 背景 + +Kokoro TTS の G2P フロントエンド Misaki は、中国語音素表記に独自の IPA バリアント(破擦音の合字記号使用、二重母音の非音節化符号、矢印型声調記号)を採用している。DotNetG2P.Chinese は現在 3 種類の出力形式(標準 IPA、piper-plus 互換 IPA、注音符号)を提供しているが、Misaki 互換形式には未対応であり、Kokoro TTS ユーザーが DotNetG2P を G2P フロントエンドとして利用できない状況にある(Issue #56)。 + +### ゴール + +既存の `PinyinToIpa.cs` / `PinyinToPiperIpa.cs` / `PinyinToZhuyin.cs` と同じ変換クラスパターンで `PinyinToMisaki.cs` を新規作成するための、**声母・韻母・声調の全マッピングテーブルを確定する**。本チケットのスコープはテーブル定義のみであり、Convert メソッドの統合は後続 T02 で行う。 + +### 達成基準 + +- 声母 22 エントリ、韻母 32 エントリ、声調 5 エントリすべてのマッピングが確定し、コードに `Dictionary` / `Dictionary` / `string[]` として実装されていること +- PinyinToIpa との差異が明確にドキュメント化されていること +- 全マッピングのユニットテストが通過すること + +## 2. 実装する内容の詳細 + +### 2.1 声母テーブル(22 エントリ) + +`PinyinToIpa.cs` の `s_initialIpa` を基準とし、Misaki で異なる表記を使用する箇所を太字で示す。 + +| # | Initial enum | ピンイン | PinyinToIpa(標準 IPA) | PinyinToMisaki | Unicode シーケンス | 差異 | +|---|-------------|---------|------------------------|----------------|-------------------|------| +| 1 | `B` | b | p | p | `p` | | +| 2 | `P` | p | ph | ph | `p\u02B0` | | +| 3 | `M` | m | m | m | `m` | | +| 4 | `F` | f | f | f | `f` | | +| 5 | `D` | d | t | t | `t` | | +| 6 | `T` | t | th | th | `t\u02B0` | | +| 7 | `N` | n | n | n | `n` | | +| 8 | `L` | l | l | l | `l` | | +| 9 | `G` | g | k | k | `k` | | +| 10 | `K` | k | kh | kh | `k\u02B0` | | +| 11 | `H` | h | x | x | `x` | | +| 12 | `J` | j | t\u0255 (tc) | **\u02A8** (**cc**) | **`\u02A8`** | **tc -> cc (U+02A8 合字)** | +| 13 | `Q` | q | t\u0255\u02B0 (tch) | **\u02A8\u02B0** (**cch**) | **`\u02A8\u02B0`** | **tch -> cch (U+02A8 合字 + 有気)** | +| 14 | `X` | x | \u0255 (c) | c | `\u0255` | | +| 15 | `Zh` | zh | \u0288\u0282 (ts) | \u0288\u0282 (ts) | `\u0288\u0282` | | +| 16 | `Ch` | ch | \u0288\u0282\u02B0 (tsh) | \u0288\u0282\u02B0 (tsh) | `\u0288\u0282\u02B0` | | +| 17 | `Sh` | sh | \u0282 (s) | \u0282 (s) | `\u0282` | | +| 18 | `R` | r | \u027B (r) | \u027B (r) | `\u027B` | | +| 19 | `Z` | z | ts | **\u02A6** (**ts**) | **`\u02A6`** | **ts 2文字 -> U+02A6 合字** | +| 20 | `C` | c | ts\u02B0 (tsh) | **\u02A6\u02B0** (**tsh**) | **`\u02A6\u02B0`** | **tsh -> U+02A6 合字 + 有気** | +| 21 | `S` | s | s | s | `s` | | +| 22 | `Y` | y | j | j | `j` | | +| 23 | `W` | w | w | w | `w` | | + +**差異まとめ(声母):** + +- **j** (Initial.J): 標準 IPA `tɕ` (U+0074 U+0255) -> Misaki `ʨ` (U+02A8、ラテン小文字 TC ダイグラフ) +- **q** (Initial.Q): 標準 IPA `tɕʰ` (U+0074 U+0255 U+02B0) -> Misaki `ʨʰ` (U+02A8 U+02B0) +- **z** (Initial.Z): 標準 IPA `ts` (U+0074 U+0073) -> Misaki `ʦ` (U+02A6、ラテン小文字 TS ダイグラフ) +- **c** (Initial.C): 標準 IPA `tsʰ` (U+0074 U+0073 U+02B0) -> Misaki `ʦʰ` (U+02A6 U+02B0) + +### 2.2 韻母テーブル(32 エントリ) + +`PinyinToIpa.cs` の `s_finalIpa` を基準とし、Misaki で異なる表記を使用する箇所を太字で示す。Misaki の主な差異は、二重母音の滑り音(off-glide/on-glide)に非音節化符号 (U+032F, COMBINING INVERTED BREVE BELOW) を付与する点にある。具体的には `ɪ` -> `i̯`、`ʊ` -> `u̯` に変換される。 + +| # | Final enum | ピンイン | PinyinToIpa(標準 IPA) | PinyinToMisaki | Unicode シーケンス | 差異 | +|---|-----------|---------|------------------------|----------------|-------------------|------| +| 1 | `A` | a | a | a | `a` | | +| 2 | `O` | o | o | o | `o` | | +| 3 | `E` | e | \u0264 (ɤ) | \u0264 (ɤ) | `\u0264` | | +| 4 | `Ai` | ai | a\u026A (aɪ) | **ai\u032F** (**ai̯**) | **`ai\u032F`** | **ɪ -> i + 非音節化符号** | +| 5 | `Ei` | ei | e\u026A (eɪ) | **ei\u032F** (**ei̯**) | **`ei\u032F`** | **ɪ -> i + 非音節化符号** | +| 6 | `Ao` | ao | a\u028A (aʊ) | **au\u032F** (**au̯**) | **`au\u032F`** | **ʊ -> u + 非音節化符号** | +| 7 | `Ou` | ou | o\u028A (oʊ) | **ou\u032F** (**ou̯**) | **`ou\u032F`** | **ʊ -> u + 非音節化符号** | +| 8 | `An` | an | an | an | `an` | | +| 9 | `En` | en | \u0259n (ən) | \u0259n (ən) | `\u0259n` | | +| 10 | `Ang` | ang | a\u014B (aŋ) | a\u014B (aŋ) | `a\u014B` | | +| 11 | `Eng` | eng | \u0259\u014B (əŋ) | \u0259\u014B (əŋ) | `\u0259\u014B` | | +| 12 | `Ong` | ong | \u028A\u014B (ʊŋ) | **u\u032F\u014B** (**u̯ŋ**) | **`u\u032F\u014B`** | **ʊ -> u + 非音節化符号** | +| 13 | `I` | i | i | i | `i` | | +| 14 | `Ia` | ia | ia | ia | `ia` | | +| 15 | `Ie` | ie | i\u025B (iɛ) | i\u025B (iɛ) | `i\u025B` | | +| 16 | `Iao` | iao | ia\u028A (iaʊ) | **iau\u032F** (**iau̯**) | **`iau\u032F`** | **ʊ -> u + 非音節化符号** | +| 17 | `Iu` | iu (iou) | io\u028A (ioʊ) | **iou\u032F** (**iou̯**) | **`iou\u032F`** | **ʊ -> u + 非音節化符号** | +| 18 | `Ian` | ian | i\u025Bn (iɛn) | i\u025Bn (iɛn) | `i\u025Bn` | | +| 19 | `In` | in | in | in | `in` | | +| 20 | `Iang` | iang | ia\u014B (iaŋ) | ia\u014B (iaŋ) | `ia\u014B` | | +| 21 | `Ing` | ing | i\u014B (iŋ) | i\u014B (iŋ) | `i\u014B` | | +| 22 | `Iong` | iong | i\u028A\u014B (iʊŋ) | **iu\u032F\u014B** (**iu̯ŋ**) | **`iu\u032F\u014B`** | **ʊ -> u + 非音節化符号** | +| 23 | `U` | u | u | u | `u` | | +| 24 | `Ua` | ua | ua | ua | `ua` | | +| 25 | `Uo` | uo | uo | uo | `uo` | | +| 26 | `Uai` | uai | ua\u026A (uaɪ) | **uai\u032F** (**uai̯**) | **`uai\u032F`** | **ɪ -> i + 非音節化符号** | +| 27 | `Ui` | ui (uei) | ue\u026A (ueɪ) | **uei\u032F** (**uei̯**) | **`uei\u032F`** | **ɪ -> i + 非音節化符号** | +| 28 | `Uan` | uan | uan | uan | `uan` | | +| 29 | `Un` | un (uen) | u\u0259n (uən) | u\u0259n (uən) | `u\u0259n` | | +| 30 | `Uang` | uang | ua\u014B (uaŋ) | ua\u014B (uaŋ) | `ua\u014B` | | +| 31 | `Ueng` | ueng | u\u0259\u014B (uəŋ) | u\u0259\u014B (uəŋ) | `u\u0259\u014B` | | +| 32 | `V` | u | y | y | `y` | | +| 33 | `Ve` | ue | y\u025B (yɛ) | y\u025B (yɛ) | `y\u025B` | | +| 34 | `Van` | uan | yan | yan | `yan` | | +| 35 | `Vn` | un | yn | yn | `yn` | | +| 36 | `Er` | er | \u0259\u027B (əɻ) | \u0259\u027B (əɻ) | `\u0259\u027B` | | + +**差異まとめ(韻母):** + +全 7 箇所の差異は以下の 2 パターンに分類される: + +1. **ɪ (U+026A) -> i + 非音節化符号 (U+032F)**: Ai, Ei, Uai, Ui の 4 韻母 +2. **ʊ (U+028A) -> u + 非音節化符号 (U+032F)**: Ao, Ou, Ong, Iao, Iu, Iong の 6 韻母 + +**特殊韻母(そり舌・歯茎)について:** + +PinyinToIpa では `zh/ch/sh/r + i` をそり舌母音 `ɻ̩` (U+027B U+0329)、`z/c/s + i` を歯茎母音 `ɹ̩` (U+0279 U+0329) に変換するが、Misaki ではこれらをそのまま踏襲する(変更なし)。 + +### 2.3 声調テーブル(5 エントリ) + +PinyinToIpa が IPA tone letters を使用するのに対し、Misaki は矢印記号を使用する。 + +| # | Tone enum | 声調名 | PinyinToIpa(IPA tone letters) | PinyinToMisaki(矢印記号) | Unicode シーケンス | 差異 | +|---|----------|-------|-------------------------------|--------------------------|-------------------|------| +| 1 | `Neutral` (0) | 軽声 | (なし) | (なし) | `""` | | +| 2 | `First` (1) | 陰平 (55) | **\u02E5\u02E5** (**˥˥**) | **\u2192** (**→**) | **`\u2192`** | **tone letters -> 矢印** | +| 3 | `Second` (2) | 陽平 (35) | **\u02E7\u02E5** (**˧˥**) | **\u2197** (**↗**) | **`\u2197`** | **tone letters -> 矢印** | +| 4 | `Third` (3) | 上声 (214) | **\u02E8\u02E9\u02E6** (**˨˩˦**) | **\u2193** (**↓**) | **`\u2193`** | **tone letters -> 矢印** | +| 5 | `Fourth` (4) | 去声 (51) | **\u02E5\u02E9** (**˥˩**) | **\u2198** (**↘**) | **`\u2198`** | **tone letters -> 矢印** | + +**差異まとめ(声調):** + +全 4 声調(軽声を除く)が異なる。IPA tone letters(複数文字の声調レベル記号)から、単一の Unicode 矢印記号に変更される。 + +### 2.4 実装ファイル + +**新規作成:** `src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs` + +```csharp +internal static class PinyinToMisaki +{ + // 声母テーブル: Dictionary + // 韻母テーブル: Dictionary + // 声調テーブル: string[] + // そり舌母音・歯茎母音: PinyinToIpa と同一 +} +``` + +テーブルのみを定義し、Convert メソッドは T02 で実装する。ただし、テーブル参照のための internal static なアクセサ(`GetInitialIpa`, `GetFinalIpa`, `GetToneMarker` 等)は本チケットで定義してもよい。 + +## 3. 実装するために必要なエージェントチームの役割と人数 + +| 役割 | 人数 | 担当内容 | +|------|------|---------| +| 実装エージェント | 1 名 | `PinyinToMisaki.cs` のテーブル定義コード作成、Unicode エスケープシーケンスの正確な記述 | +| テストエージェント | 1 名 | マッピングテーブルの全エントリに対するユニットテスト作成 | +| レビューエージェント | 1 名 | Unicode コードポイントの正確性検証、Misaki 公式実装との照合、言語学的正確性確認 | + +**合計: 3 名** + +実装自体はテーブル定義のみのため小規模だが、Unicode 文字の正確性が極めて重要であるため、レビューエージェントの参加が必須である。 + +## 4. 提供範囲とテスト項目 + +### スコープ + +- `PinyinToMisaki.cs` 内のマッピングテーブル(`Dictionary`、`Dictionary`、`string[]`)の定義 +- テーブルのキーが全 enum 値を網羅していることの保証 +- 各テーブルエントリに対するユニットテスト + +### スコープ外 + +- Convert メソッドの実装(T02) +- ChineseG2PEngine への統合(T02 以降) +- 既存の ToIpa / ToPiperIpa / ToZhuyin API への影響(なし) + +### ユニットテスト項目 + +**テストクラス:** `tests/DotNetG2P.Tests/Chinese/PinyinToMisakiMappingTests.cs` + +#### 声母テスト(22 件) + +各 `Initial` enum 値に対して、テーブルから取得した文字列が期待する Unicode シーケンスと完全一致することを検証する。 + +| テストケース | 入力 | 期待出力 | 検証ポイント | +|------------|------|---------|------------| +| `InitialB_MapsToP` | `Initial.B` | `"p"` | 同一 | +| `InitialP_MapsToPh` | `Initial.P` | `"p\u02B0"` | 同一 | +| `InitialM_MapsToM` | `Initial.M` | `"m"` | 同一 | +| `InitialF_MapsToF` | `Initial.F` | `"f"` | 同一 | +| `InitialD_MapsToT` | `Initial.D` | `"t"` | 同一 | +| `InitialT_MapsToTh` | `Initial.T` | `"t\u02B0"` | 同一 | +| `InitialN_MapsToN` | `Initial.N` | `"n"` | 同一 | +| `InitialL_MapsToL` | `Initial.L` | `"l"` | 同一 | +| `InitialG_MapsToK` | `Initial.G` | `"k"` | 同一 | +| `InitialK_MapsToKh` | `Initial.K` | `"k\u02B0"` | 同一 | +| `InitialH_MapsToX` | `Initial.H` | `"x"` | 同一 | +| **`InitialJ_MapsToTcLigature`** | `Initial.J` | `"\u02A8"` | **合字 U+02A8** | +| **`InitialQ_MapsToTcLigatureAspirated`** | `Initial.Q` | `"\u02A8\u02B0"` | **合字 + 有気** | +| `InitialX_MapsToAlveolopalatalFricative` | `Initial.X` | `"\u0255"` | 同一 | +| `InitialZh_MapsToRetroflexAffricate` | `Initial.Zh` | `"\u0288\u0282"` | 同一 | +| `InitialCh_MapsToRetroflexAffricateAspirated` | `Initial.Ch` | `"\u0288\u0282\u02B0"` | 同一 | +| `InitialSh_MapsToRetroflexFricative` | `Initial.Sh` | `"\u0282"` | 同一 | +| `InitialR_MapsToRetroflexApproximant` | `Initial.R` | `"\u027B"` | 同一 | +| **`InitialZ_MapsToTsLigature`** | `Initial.Z` | `"\u02A6"` | **合字 U+02A6** | +| **`InitialC_MapsToTsLigatureAspirated`** | `Initial.C` | `"\u02A6\u02B0"` | **合字 + 有気** | +| `InitialS_MapsToS` | `Initial.S` | `"s"` | 同一 | +| `InitialY_MapsToPalatalApproximant` | `Initial.Y` | `"j"` | 同一 | +| `InitialW_MapsToLabialVelarApproximant` | `Initial.W` | `"w"` | 同一 | + +#### 韻母テスト(32 件) + +各 `Final` enum 値に対して、テーブルから取得した文字列が期待する Unicode シーケンスと完全一致することを検証する。差異のある 10 韻母を重点的にテストする。 + +| テストケース | 入力 | 期待出力 | 検証ポイント | +|------------|------|---------|------------| +| `FinalA_MapsToA` | `Final.A` | `"a"` | 同一 | +| `FinalO_MapsToO` | `Final.O` | `"o"` | 同一 | +| `FinalE_MapsToRamishorn` | `Final.E` | `"\u0264"` | 同一 | +| **`FinalAi_MapsToAiWithInvertedBreve`** | `Final.Ai` | `"ai\u032F"` | **非音節化符号** | +| **`FinalEi_MapsToEiWithInvertedBreve`** | `Final.Ei` | `"ei\u032F"` | **非音節化符号** | +| **`FinalAo_MapsToAuWithInvertedBreve`** | `Final.Ao` | `"au\u032F"` | **非音節化符号** | +| **`FinalOu_MapsToOuWithInvertedBreve`** | `Final.Ou` | `"ou\u032F"` | **非音節化符号** | +| `FinalAn_MapsToAn` | `Final.An` | `"an"` | 同一 | +| `FinalEn_MapsToSchwan` | `Final.En` | `"\u0259n"` | 同一 | +| `FinalAng_MapsToAng` | `Final.Ang` | `"a\u014B"` | 同一 | +| `FinalEng_MapsToSchwaEng` | `Final.Eng` | `"\u0259\u014B"` | 同一 | +| **`FinalOng_MapsToUInvertedBreveNg`** | `Final.Ong` | `"u\u032F\u014B"` | **非音節化符号** | +| `FinalI_MapsToI` | `Final.I` | `"i"` | 同一 | +| `FinalIa_MapsToIa` | `Final.Ia` | `"ia"` | 同一 | +| `FinalIe_MapsToIOpenE` | `Final.Ie` | `"i\u025B"` | 同一 | +| **`FinalIao_MapsToIauWithInvertedBreve`** | `Final.Iao` | `"iau\u032F"` | **非音節化符号** | +| **`FinalIu_MapsToIouWithInvertedBreve`** | `Final.Iu` | `"iou\u032F"` | **非音節化符号** | +| `FinalIan_MapsToIOpenEn` | `Final.Ian` | `"i\u025Bn"` | 同一 | +| `FinalIn_MapsToIn` | `Final.In` | `"in"` | 同一 | +| `FinalIang_MapsToIaEng` | `Final.Iang` | `"ia\u014B"` | 同一 | +| `FinalIng_MapsToIEng` | `Final.Ing` | `"i\u014B"` | 同一 | +| **`FinalIong_MapsToIuInvertedBreveNg`** | `Final.Iong` | `"iu\u032F\u014B"` | **非音節化符号** | +| `FinalU_MapsToU` | `Final.U` | `"u"` | 同一 | +| `FinalUa_MapsToUa` | `Final.Ua` | `"ua"` | 同一 | +| `FinalUo_MapsToUo` | `Final.Uo` | `"uo"` | 同一 | +| **`FinalUai_MapsToUaiWithInvertedBreve`** | `Final.Uai` | `"uai\u032F"` | **非音節化符号** | +| **`FinalUi_MapsToUeiWithInvertedBreve`** | `Final.Ui` | `"uei\u032F"` | **非音節化符号** | +| `FinalUan_MapsToUan` | `Final.Uan` | `"uan"` | 同一 | +| `FinalUn_MapsToUSchwan` | `Final.Un` | `"u\u0259n"` | 同一 | +| `FinalUang_MapsToUaEng` | `Final.Uang` | `"ua\u014B"` | 同一 | +| `FinalUeng_MapsToUSchwaEng` | `Final.Ueng` | `"u\u0259\u014B"` | 同一 | +| `FinalV_MapsToY` | `Final.V` | `"y"` | 同一 | +| `FinalVe_MapsToYOpenE` | `Final.Ve` | `"y\u025B"` | 同一 | +| `FinalVan_MapsToYan` | `Final.Van` | `"yan"` | 同一 | +| `FinalVn_MapsToYn` | `Final.Vn` | `"yn"` | 同一 | +| `FinalEr_MapsToSchwaRetroflex` | `Final.Er` | `"\u0259\u027B"` | 同一 | + +#### 声調テスト(5 件) + +| テストケース | 入力 | 期待出力 | 検証ポイント | +|------------|------|---------|------------| +| `ToneNeutral_MapsToEmpty` | `Tone.Neutral` (0) | `""` | 軽声は空文字 | +| **`ToneFirst_MapsToRightArrow`** | `Tone.First` (1) | `"\u2192"` | **→** | +| **`ToneSecond_MapsToNorthEastArrow`** | `Tone.Second` (2) | `"\u2197"` | **↗** | +| **`ToneThird_MapsToDownArrow`** | `Tone.Third` (3) | `"\u2193"` | **↓** | +| **`ToneFourth_MapsToSouthEastArrow`** | `Tone.Fourth` (4) | `"\u2198"` | **↘** | + +#### テーブル網羅性テスト(3 件) + +| テストケース | 検証内容 | +|------------|---------| +| `InitialTable_CoversAllEnumValues` | `Initial` enum の `None` 以外の全 22 値がテーブルのキーに存在する | +| `FinalTable_CoversAllEnumValues` | `Final` enum の `None` 以外の全 35 値がテーブルのキーに存在する | +| `ToneTable_HasCorrectLength` | 声調配列の長さが 5(Neutral=0 ~ Fourth=4) | + +#### E2E テスト(本チケット外、T02 で実施) + +T02 で Convert メソッド統合後に以下を検証する: + +- `"mā"` -> `"ma\u2192"` (第 1 声、矢印) +- `"jīn"` -> `"\u02A8in\u2192"` (j の合字 + 第 1 声) +- `"zài"` -> `"\u02A6ai\u032F\u2198"` (z の合字 + 非音節化 + 第 4 声) +- `"zhōng"` -> `"\u0288\u0282u\u032F\u014B\u2192"` (そり舌 + ong 非音節化 + 第 1 声) + +## 5. 実装に関する懸念事項とレビュー項目 + +### Unicode 文字の正確性 + +1. **合字文字のコードポイント確認**: `ʨ` (U+02A8) と `ʦ` (U+02A6) は IPA Extensions ブロック (U+0250-U+02AF) に属する合字文字である。これらが正しく .NET の `char` / `string` で扱えることを確認すること(BMP 内のため問題ないはず)。 + +2. **非音節化符号の結合文字性**: U+032F (COMBINING INVERTED BREVE BELOW) は結合文字であり、先行する基底文字に付与される。テーブル内で `"ai\u032F"` のように末尾に配置した場合、`i` に結合することを確認すること。 + +3. **矢印記号のフォント互換性**: U+2192/U+2197/U+2193/U+2198 は Arrows ブロックに属し、IPA 文字と同列に出力される。Kokoro TTS が実際にこれらのコードポイントを期待しているかを Misaki の公式実装と照合すること。 + +### 言語学的正確性 + +4. **ong の Misaki 表記**: 標準 IPA では `ʊŋ` だが、Misaki が `u̯ŋ` を採用する場合、音韻論的には非音節化 u を明示する表記となる。PinyinToIpa との意味的一貫性を確認すること。 + +5. **iu/ui の展開形**: PinyinToIpa では `iu` を `ioʊ`、`ui` を `ueɪ` と展開しているが、Misaki でも同様に `iou̯` / `uei̯` と展開するかを確認すること。Misaki が `iu̯` / `ui̯` のような縮約形を使用している可能性がある。 + +### コード品質 + +6. **既存変換クラスとの整合性**: `PinyinToIpa.cs` / `PinyinToPiperIpa.cs` と同一の `internal static class` パターン、同一のフィールド命名規則(`s_initialIpa`, `s_finalIpa`, `s_toneLetters`)に従うこと。ただし、フィールド名は `s_initialMisaki`, `s_finalMisaki`, `s_toneMisaki` 等に変更してもよい。 + +7. **テーブルの不変性**: テーブルは `static readonly` で宣言し、実行時に変更されないことを保証すること。 + +## 6. 一から作り直すとしたら + +### 現行設計の評価 + +現在の変換クラス群(`PinyinToIpa`, `PinyinToPiperIpa`, `PinyinToZhuyin`)は、各クラスが独立した `Dictionary` テーブルを持つ「コピー&修正」パターンを採用している。このアプローチは以下の利点がある: + +- 各変換形式が完全に自己完結しており、依存関係がない +- 新しい形式の追加が既存コードに影響しない +- デバッグ時にテーブルを直接参照できる + +一方で以下の課題がある: + +- 22+35+5 = 62 エントリ x 4 形式 = 248 テーブルエントリの管理が必要(今後さらに増加) +- テーブル間の差異が暗黙的であり、どのエントリが異なるか一覧しにくい +- 新しい Initial/Final が追加された場合、全変換クラスを更新する必要がある + +### 代替設計案 + +#### A案: 差分テーブル方式 + +PinyinToIpa を基準テーブルとし、Misaki は差分のみ定義する。 + +```csharp +internal static class PinyinToMisaki +{ + // PinyinToIpa のテーブルを基準に、差異のあるエントリのみ上書き + private static readonly Dictionary s_initialOverrides = new() + { + [Initial.J] = "\u02A8", // tɕ -> ʨ + [Initial.Q] = "\u02A8\u02B0", // tɕʰ -> ʨʰ + [Initial.Z] = "\u02A6", // ts -> ʦ + [Initial.C] = "\u02A6\u02B0", // tsʰ -> ʦʰ + }; + + internal static string GetInitial(Initial i) => + s_initialOverrides.TryGetValue(i, out var v) ? v : PinyinToIpa.GetInitial(i); +} +``` + +利点: 差異が明示的、メンテナンスコストが低い。欠点: PinyinToIpa への依存が発生し、PinyinToIpa の変更が Misaki に波及する。 + +#### B案: 型安全 enum + 属性方式 + +各音素 enum 値に属性で全形式のマッピングを定義する。 + +```csharp +public enum Initial : byte +{ + [IpaMapping("tɕ")] + [MisakiMapping("ʨ")] + [PiperMapping("tɕ")] + J, + // ... +} +``` + +利点: 音素と全マッピングが一箇所に集約される。欠点: 属性ベースはリフレクション依存でパフォーマンスに影響、.NET Standard 2.1 でのソース生成器非対応、既存設計との大幅な乖離。 + +#### C案: TSV/CSV 駆動 + +マッピングを外部 TSV ファイルとして管理し、起動時にロードする。 + +```tsv +Initial IPA Misaki Piper Zhuyin +J tɕ ʨ tɕ ㄐ +Q tɕʰ ʨʰ tɕʰ ㄑ +``` + +利点: マッピングの一覧性が最高、非プログラマでも編集可能。欠点: 起動時パースコスト、Unicode 文字の TSV 内表現が不安定(エディタ依存)、embedded resource 管理の複雑化。 + +### 推奨 + +**現行の独立テーブルパターン(コピー&修正方式)を継続する。** 理由: + +1. 変換形式は 4 種類にとどまり、管理負荷は許容範囲内 +2. 既存の 3 クラスとの一貫性を維持できる +3. 各テーブルが自己完結しており、バグの局所化が容易 +4. ランタイムのパフォーマンスオーバーヘッドがゼロ + +ただし、本チケットのテーブル設計時に差異を明確にドキュメント化し(本チケット自体がその役割を果たす)、将来的にマッピング形式が 6 種以上に増えた場合は A 案(差分テーブル方式)への移行を検討する。 + +## 7. 後続タスクへの連絡事項 + +### T02(Convert メソッド統合)に伝える情報 + +1. **テーブルフィールド名**: `s_initialMisaki`, `s_finalMisaki`, `s_toneMisaki` を使用(PinyinToIpa の `s_initialIpa` 等と区別するため)。 + +2. **そり舌・歯茎母音の処理**: `s_retroflexApical` (`ɻ̩`) と `s_alveolarApical` (`ɹ̩`) は PinyinToIpa と同一値を使用する。PinyinToMisaki 内にも同じフィールドを定義するか、共通化するかは T02 で判断すること。 + +3. **声調の配置位置**: Misaki の矢印声調は音節末に付加する(PinyinToIpa と同じ位置)。 + +4. **Convert メソッドのシグネチャ**: `PinyinToIpa.Convert(string pinyin, bool includeTones)` と同一のシグネチャを推奨。PinyinToPiperIpa のように声調なし固定にはしない(Misaki は声調を使用するため)。 + +5. **ShouldOmitSemivowel ロジック**: Y/W 声母の省略判定は PinyinToIpa と同一ロジック。共通化するか PinyinToMisaki にコピーするかは T02 で判断すること。 + +6. **韻母テーブルの `Iu` / `Ui` の展開形**: PinyinToIpa は `Iu` -> `ioʊ`、`Ui` -> `ueɪ` と展開する。Misaki では `iou̯` / `uei̯` とした。T02 実装時に Misaki 公式出力と照合し、縮約形を使う場合はテーブルを修正すること。 + +## 8. 紐づけ + +- **マイルストーン**: Mi1(PinyinToMisaki 変換クラス) +- **依存**: なし +- **後続**: T02(Convert メソッド統合・ChineseG2PEngine への組み込み) +- **関連 Issue**: #56 From 0281300d70501f413fd71887d02f09a6a9e65067 Mon Sep 17 00:00:00 2001 From: yousan Date: Sun, 12 Apr 2026 22:03:02 +0900 Subject: [PATCH 03/21] =?UTF-8?q?docs:=20T02=20PinyinToMisaki=20Convert=20?= =?UTF-8?q?=E3=83=A1=E3=82=BD=E3=83=83=E3=83=89=E7=B5=B1=E5=90=88=20?= =?UTF-8?q?=E3=83=81=E3=82=B1=E3=83=83=E3=83=88=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mi1 フェーズ2枚目。T01で定義したマッピングテーブルを使用して Convert/ConvertSyllable メソッドを実装するタスク。そり舌母音(zh/ch/sh/r+i)、 歯茎母音(z/c/s+i)、半母音省略(y/w)の特殊処理を含む。PinyinToIpa/ PinyinToPiperIpa/PinyinToMisaki の共通部分を抽象基底クラスで統合する リファクタリング案もセクション6に記載。 関連: #56 --- docs/tickets/T02-misaki-convert-method.md | 405 ++++++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 docs/tickets/T02-misaki-convert-method.md diff --git a/docs/tickets/T02-misaki-convert-method.md b/docs/tickets/T02-misaki-convert-method.md new file mode 100644 index 0000000..fd01571 --- /dev/null +++ b/docs/tickets/T02-misaki-convert-method.md @@ -0,0 +1,405 @@ +--- +ticket: T02 +title: PinyinToMisaki Convert メソッド統合 +milestone: Mi1 +status: 未着手 +depends_on: [T01] +blocks: [T03] +--- + +# T02: PinyinToMisaki Convert メソッド統合 + +## 1. タスク目的とゴール + +T01 で定義済みの声母・韻母・声調の Misaki マッピングテーブル(`s_initialMisaki`, `s_finalMisaki`, 声調矢印テーブル)を使い、`PinyinToMisaki` クラスに `Convert()` および `ConvertSyllable()` メソッドを実装する。 + +**ゴール:** +- `PinyinToMisaki.Convert(string pinyin)` — ピンイン文字列を受け取り、Misaki 互換の音素文字列を返す +- `PinyinToMisaki.ConvertSyllable(PinyinSyllable syllable, bool includeTones)` — パース済み音節構造体から Misaki 文字列を生成する +- `dotnet build` が通過すること +- Convert 単体のユニットテストが全件パスすること + +## 2. 実装する内容の詳細 + +### 2.1 ファイル配置 + +``` +src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs +``` + +T01 で作成済みのファイルにメソッドを追加する形を想定。T01 で既にクラスの骨格とマッピングテーブルが定義されている前提。 + +### 2.2 メソッドシグネチャ + +```csharp +namespace DotNetG2P.Chinese +{ + internal static class PinyinToMisaki + { + // --- T01 で定義済み --- + // private static readonly Dictionary s_initialMisaki; + // private static readonly Dictionary s_finalMisaki; + // private static readonly string[] s_toneArrows; // 声調矢印テーブル + + // --- T02 で実装 --- + + /// + /// 声調記号付きピンインを Misaki 互換表記に変換する(声調矢印付き)。 + /// + public static string Convert(string pinyin); + + /// + /// 声調記号付きピンインを Misaki 互換表記に変換する。 + /// + public static string Convert(string pinyin, bool includeTones); + + /// + /// PinyinSyllable を Misaki 互換表記に変換する。 + /// + internal static string ConvertSyllable(PinyinSyllable syllable, bool includeTones); + } +} +``` + +### 2.3 処理フロー — Convert() + +既存の `PinyinToIpa.Convert()` と同一の構造を踏襲する。 + +``` +入力: ピンイン文字列 (例: "zhōng", "ma1", "lǜ") + │ + ├─ 1. null/空チェック → string.Empty を返す + │ + ├─ 2. ToneConverter.ToToneMarked(pinyin) で数字声調形式を声調記号付きに正規化 + │ 例: "ma1" → "mā" + │ + ├─ 3. PinyinParser.TryParse(normalized, out syllable) + │ パース失敗 → string.Empty を返す + │ 例: "zhōng" → PinyinSyllable(Initial.Zh, Final.Ong, Tone.First) + │ + └─ 4. ConvertSyllable(syllable, includeTones) を呼び出して結果を返す +``` + +引数なし `Convert(string pinyin)` は `Convert(pinyin, true)` に委譲する(PinyinToIpa と同一パターン)。 + +### 2.4 処理フロー — ConvertSyllable() + +これが変換の中核ロジックである。`PinyinToIpa.ConvertSyllable()` の全ロジックフローを踏襲しつつ、マッピングテーブルのみを Misaki 用に差し替える。 + +以下に `PinyinToIpa.ConvertSyllable()` の全ロジックフローを詳述する。 + +#### ステップ A: StringBuilder 初期化 + +```csharp +var sb = new StringBuilder(16); +``` + +16 文字の初期容量で StringBuilder を作成。Misaki 表記でも十分なサイズ。 + +#### ステップ B: 声母の変換 + +``` +syllable.Initial != Initial.None の場合: + │ + ├─ (a) Initial が Y または W の場合 → 半母音省略判定 + │ │ + │ ├─ ShouldOmitSemivowel(Initial, Final) == true + │ │ → 声母を出力しない(韻母が既に対応する半母音で始まるため) + │ │ + │ └─ ShouldOmitSemivowel == false + │ → s_initialMisaki[syllable.Initial] を出力 + │ + └─ (b) それ以外の声母 + → s_initialMisaki[syllable.Initial] を出力 +``` + +**ShouldOmitSemivowel の判定ロジック(PinyinToIpa から完全再利用):** + +- `Initial.Y` の場合、以下の韻母なら半母音 j を省略: + - `Final.I`, `Final.In`, `Final.Ing` — 韻母の IPA/Misaki が `i` で始まるため + - `Final.V`, `Final.Ve`, `Final.Van`, `Final.Vn` — 韻母の IPA/Misaki が `y`(円唇前舌高母音)で始まるため +- `Initial.W` の場合、以下の韻母なら半母音 w を省略: + - `Final.U`, `Final.Un` — 韻母の IPA/Misaki が `u` で始まるため +- それ以外 → 省略しない (false) + +#### ステップ C: 韻母の変換(特殊母音の分岐を含む) + +``` +syllable.Final != Final.None の場合: + │ + ├─ (c1) Final == Final.I かつ IsRetroflex(Initial) == true + │ → そり舌母音を出力(後述の特殊母音処理を参照) + │ 対象声母: Initial.Zh, Initial.Ch, Initial.Sh, Initial.R + │ 例: "zhi" → 声母(zh の Misaki) + そり舌母音(Misaki) + │ + ├─ (c2) Final == Final.I かつ IsAlveolar(Initial) == true + │ → 歯茎母音を出力(後述の特殊母音処理を参照) + │ 対象声母: Initial.Z, Initial.C, Initial.S + │ 例: "zi" → 声母(z の Misaki) + 歯茎母音(Misaki) + │ + └─ (c3) 上記以外 + → s_finalMisaki[syllable.Final] を出力 +``` + +**IsRetroflex の判定:** +```csharp +Initial == Initial.Zh || Initial == Initial.Ch || Initial == Initial.Sh || Initial == Initial.R +``` + +**IsAlveolar の判定:** +```csharp +Initial == Initial.Z || Initial == Initial.C || Initial == Initial.S +``` + +#### ステップ D: 声調矢印の付与 + +``` +includeTones == true かつ syllable.Tone != Tone.Neutral の場合: + → s_toneArrows[(int)syllable.Tone] を出力 +``` + +Misaki では IPA の声調文字 (˥˥ 等) の代わりに矢印文字を使用する。具体的なマッピングは T01 で定義済み。 + +#### ステップ E: 結果の返却 + +```csharp +return sb.ToString(); +``` + +### 2.5 PinyinToIpa との構造比較 — 同一部分と差異 + +| 項目 | PinyinToIpa | PinyinToMisaki | +|------|-------------|----------------| +| Convert() の入口処理 | 同一 | **同一**: null チェック → ToneConverter → PinyinParser → ConvertSyllable | +| 半母音省略判定 | ShouldOmitSemivowel | **同一ロジック**: Y/W + 特定韻母で省略 | +| そり舌母音 (zh/ch/sh/r + i) | `ɻ̩` (U+027B U+0329) | **差異**: T01 で定義する Misaki 表記 | +| 歯茎母音 (z/c/s + i) | `ɹ̩` (U+0279 U+0329) | **差異**: T01 で定義する Misaki 表記 | +| 声母マッピング | s_initialIpa | **差異**: s_initialMisaki(T01 定義) | +| 韻母マッピング | s_finalIpa | **差異**: s_finalMisaki(T01 定義) | +| 声調マッピング | s_toneLetters (IPA tone letters) | **差異**: s_toneArrows(Misaki 矢印記号、T01 定義) | +| includeTones パラメータ | あり | **同一**: true/false で声調出力を制御 | +| IsRetroflex / IsAlveolar | private static メソッド | **同一ロジック**: 再実装 or 共有ヘルパーから呼び出し | + +### 2.6 特殊母音の処理 — そり舌母音・歯茎母音 + +中国語音韻学上、以下の2つの特殊母音はピンイン表記上「i」と書かれるが、実際の発音は通常の /i/ とは全く異なる。 + +**そり舌母音(zhi, chi, shi, ri の韻母):** +- IPA: `ɻ̩` (U+027B U+0329 — そり舌接近音 + 音節主音) +- piper-plus: `ɻ̩` (U+027B U+0329 — IPA と同一) +- Misaki: T01 のマッピングテーブルで定義(要確認) + +**歯茎母音(zi, ci, si の韻母):** +- IPA: `ɹ̩` (U+0279 U+0329 — 歯茎接近音 + 音節主音) +- piper-plus: `ɨ` (U+0268 — 非円唇中舌高母音、簡略表記) +- Misaki: T01 のマッピングテーブルで定義(要確認) + +実装では `s_retroflexApical` および `s_alveolarApical` として static readonly string フィールドに定義する(PinyinToIpa/PinyinToPiperIpa と同一パターン)。 + +### 2.7 参照すべき既存コード + +| ファイル | 参照理由 | +|---------|---------| +| `src/DotNetG2P.Chinese/Conversion/PinyinToIpa.cs` | Convert/ConvertSyllable の構造テンプレート、ShouldOmitSemivowel/IsRetroflex/IsAlveolar のロジック | +| `src/DotNetG2P.Chinese/Conversion/PinyinToPiperIpa.cs` | 同構造の別バリアント。歯茎母音の表記差異 (`ɹ̩` vs `ɨ`) の参考 | +| `src/DotNetG2P.Chinese/Conversion/PinyinParser.cs` | TryParse の仕様。j/q/x/y 後の u→v 正規化ロジック | +| `src/DotNetG2P.Chinese/Conversion/ToneConverter.cs` | ToToneMarked / ExtractTone の仕様 | +| `src/DotNetG2P.Chinese/Models/PinyinSyllable.cs` | Initial/Final/Tone の構造体定義 | + +## 3. 実装するために必要なエージェントチームの役割と人数 + +| 役割 | 人数 | 担当内容 | +|------|------|---------| +| 実装エージェント | 1名 | PinyinToMisaki.cs に Convert/ConvertSyllable/ShouldOmitSemivowel/IsRetroflex/IsAlveolar を実装 | +| テストエージェント | 1名 | ユニットテスト作成。Convert 単体テスト、特殊母音テスト、半母音省略テスト、声調テスト | +| レビューエージェント | 1名 | PinyinToIpa との構造一貫性確認、マッピングテーブル (T01) との整合性検証、エッジケースのレビュー | + +合計 **3名**。実装自体は PinyinToIpa のコピー&マッピング差し替えが主であり、1名の実装エージェントで十分対応可能。テストとレビューを並行して進めることで効率化できる。 + +## 4. 提供範囲とテスト項目 + +### 4.1 スコープ + +**含む:** +- `PinyinToMisaki.Convert(string)` メソッド +- `PinyinToMisaki.Convert(string, bool)` メソッド +- `PinyinToMisaki.ConvertSyllable(PinyinSyllable, bool)` メソッド +- 上記に必要な private ヘルパー (`ShouldOmitSemivowel`, `IsRetroflex`, `IsAlveolar`) +- そり舌母音・歯茎母音の static readonly フィールド (`s_retroflexApical`, `s_alveolarApical`) +- Convert 単体のユニットテスト + +**含まない:** +- ChineseG2PEngine への `ToMisaki()` API 追加(T03 のスコープ) +- マッピングテーブルの定義(T01 のスコープ) +- バッチ API (`ConvertBatch` 等) +- `ConvertToPhonemes()` メソッド(PinyinToPiperIpa にあるが、Misaki で必要かは T03 以降で判断) + +### 4.2 ユニットテスト案 + +テストファイル: `tests/DotNetG2P.Tests/Chinese/PinyinToMisakiTests.cs` + +| テストカテゴリ | テストケース例 | 検証内容 | +|---------------|-------------|---------| +| **基本変換** | "mā" → 期待 Misaki 出力 | 声母 m + 韻母 a + 第1声矢印 | +| **全声調** | "mā", "má", "mǎ", "mà", "ma" | 各声調の矢印が正しいこと。軽声は矢印なし | +| **数字声調入力** | "ma1", "ma2", "ma3", "ma4" | ToneConverter 経由で正しく変換されること | +| **includeTones=false** | "mā" (tones=false) | 声調矢印が付かないこと | +| **そり舌母音** | "zhī", "chī", "shī", "rì" | zh/ch/sh/r + i で特殊母音が出力されること | +| **歯茎母音** | "zī", "cī", "sī" | z/c/s + i で特殊母音が出力されること | +| **半母音省略 (Y)** | "yī" (yi), "yīn" (yin) | j が省略されること | +| **半母音付与 (Y)** | "yā" (ya), "yáo" (yao) | j が付与されること | +| **半母音省略 (W)** | "wū" (wu) | w が省略されること | +| **半母音付与 (W)** | "wā" (wa), "wǒ" (wo) | w が付与されること | +| **ü 系韻母** | "lǜ", "nǚ", "jú" (→ jü) | V/Ve/Van/Vn 韻母が正しく変換されること | +| **null/空文字** | null, "", " " | string.Empty が返ること | +| **パース失敗** | "xyz", "123" | string.Empty が返ること | +| **全声母網羅** | b,p,m,f,d,t,n,l,g,k,h,j,q,x,zh,ch,sh,r,z,c,s 各1例 | 全21声母のマッピングが正しいこと | +| **全韻母網羅** | 各韻母の代表音節1例ずつ (36種) | 全36韻母のマッピングが正しいこと | +| **er 韻母** | "ér" | 特殊韻母 er の変換が正しいこと | + +## 5. 実装に関する懸念事項とレビュー項目 + +### 5.1 PinyinParser 依存 + +`Convert()` は `PinyinParser.TryParse()` に完全依存している。PinyinParser は j/q/x/y 後の `u` を `v`(ü系韻母)として正規化する処理を内包しており、この挙動を前提としている。PinyinParser 自体の変更は不要だが、Misaki のマッピングが ü 系韻母 (Final.V, Ve, Van, Vn) を正しくカバーしていることを T01 レビュー時に確認すること。 + +### 5.2 ShouldOmitSemivowel の再利用 + +現在 `PinyinToIpa` と `PinyinToPiperIpa` に全く同一の `ShouldOmitSemivowel` が private static で個別に存在する。`PinyinToMisaki` にも同一ロジックを3つ目としてコピーすることになる。 + +**レビュー確認項目:** +- 3クラス間で ShouldOmitSemivowel のロジックが完全一致していること +- 将来 ShouldOmitSemivowel を変更する場合、3箇所を同時に更新する必要がある旨をコメントに記載すること +- 共有ヘルパー化の是非はセクション6で考察 + +### 5.3 そり舌母音・歯茎母音の Misaki 表記 + +IPA と piper-plus で歯茎母音の表記が異なる(`ɹ̩` vs `ɨ`)前例がある。Misaki でこれらの特殊母音をどう表記するかは T01 のマッピングテーブル定義に依存する。 + +**レビュー確認項目:** +- T01 の `s_retroflexApical` / `s_alveolarApical` が Misaki (Kokoro TTS) の期待する表記と一致していること +- Misaki の中国語音素体系のドキュメントまたはソースコードとの照合 + +### 5.4 声調矢印の文字コード + +Misaki が使用する声調矢印(↑↓→ 等)の具体的な Unicode コードポイントを T01 マッピングで正確に定義しているか確認すること。IPA の声調文字 (U+02E5-U+02E9) とは全く異なる文字体系になる。 + +### 5.5 Convert のオーバーロード設計 + +`PinyinToIpa` は `Convert(string)` と `Convert(string, bool)` の2つのオーバーロードを持つ。`PinyinToPiperIpa` は声調マーカーを含めないため `Convert(string)` のみ。Misaki が声調矢印を含める/含めないの両方を必要とするか確認し、オーバーロード構成を決定する。デフォルトは声調矢印付き(`includeTones = true`)とする。 + +## 6. 一から作り直すとしたら + +### 6.1 現状の問題 — コピペパターン + +現在の `PinyinToIpa.cs` と `PinyinToPiperIpa.cs` は以下のコードが事実上のコピペである: + +- `Convert()` メソッドの入口処理(null チェック → ToneConverter → PinyinParser → ConvertSyllable 委譲) +- `ConvertSyllable()` の制御フロー(声母判定 → 半母音省略 → 韻母判定 → そり舌/歯茎分岐 → 声調付与) +- `ShouldOmitSemivowel()` — 3クラスで完全同一のロジック +- `IsRetroflex()` / `IsAlveolar()` — 3クラスで完全同一のロジック + +`PinyinToMisaki` を追加すると、これが3重コピーになる。 + +### 6.2 設計案 A: 抽象基底クラス + +```csharp +internal abstract class PinyinConverterBase +{ + // テンプレートメソッド + public TResult Convert(string pinyin, bool includeTones) + { + // 共通: null チェック → ToneConverter → PinyinParser + // → ConvertSyllable(syllable, includeTones) + } + + protected abstract string GetInitial(Initial initial); + protected abstract string GetFinal(Final final_); + protected abstract string GetRetroflexApical(); + protected abstract string GetAlveolarApical(); + protected abstract string GetToneMarker(Tone tone); + + // 共通ロジックを基底に集約 + protected static bool ShouldOmitSemivowel(Initial initial, Final final_) { ... } + protected static bool IsRetroflex(Initial initial) { ... } + protected static bool IsAlveolar(Initial initial) { ... } +} +``` + +**メリット:** 共通ロジックの一元管理。新しい出力フォーマット追加時にマッピングテーブルだけ定義すればよい。 +**デメリット:** 既存の `PinyinToIpa` / `PinyinToPiperIpa` が static class であるため、インスタンスベースの基底クラスへの移行は破壊的変更。internal なので外部 API には影響しないが、テストの修正が必要。 + +### 6.3 設計案 B: ジェネリック変換器 + マッピング構造体 + +```csharp +internal readonly struct PinyinMapping +{ + public Dictionary Initials { get; } + public Dictionary Finals { get; } + public string RetroflexApical { get; } + public string AlveolarApical { get; } + public string[] ToneMarkers { get; } // Tone enum のインデックスで参照 + public bool IncludeTonesByDefault { get; } +} + +internal static class PinyinConverter +{ + public static string Convert(string pinyin, PinyinMapping mapping, bool includeTones) { ... } + internal static string ConvertSyllable(PinyinSyllable syllable, PinyinMapping mapping, bool includeTones) { ... } +} +``` + +各フォーマットは `PinyinMapping` インスタンスを提供するだけでよい: + +```csharp +internal static class PinyinToIpa +{ + private static readonly PinyinMapping s_mapping = new PinyinMapping { ... }; + public static string Convert(string pinyin) => PinyinConverter.Convert(pinyin, s_mapping, true); +} +``` + +**メリット:** static class のまま維持できる。既存 API との互換性が高い。マッピングデータと変換ロジックが明確に分離される。 +**デメリット:** `PinyinMapping` 構造体に Dictionary を持つため、構造体としては重い(readonly struct にしても参照型フィールドがある)。class にする方が適切。 + +### 6.4 設計案 C: 共有ヘルパーの分離(最小限のリファクタリング) + +完全同一のロジックのみを共有ヘルパーとして抽出し、各クラスの static 構造は維持する: + +```csharp +internal static class PinyinConversionHelper +{ + public static bool ShouldOmitSemivowel(Initial initial, Final final_) { ... } + public static bool IsRetroflex(Initial initial) { ... } + public static bool IsAlveolar(Initial initial) { ... } +} +``` + +**メリット:** 最小限の変更で3重コピーの問題を解消。既存テストへの影響がない。 +**デメリット:** ConvertSyllable の制御フロー自体は依然としてコピペのまま。 + +### 6.5 現時点での推奨 + +現在のプロジェクトでは **設計案 C(共有ヘルパー分離)** が最も現実的。理由: + +1. `PinyinToIpa` / `PinyinToPiperIpa` は既に安定稼働中(テスト 936 件通過)であり、大規模リファクタリングのリスクを取る必要がない +2. Misaki 対応で3クラス目が追加される今が共有ヘルパー抽出の自然なタイミング +3. ConvertSyllable の制御フローは各フォーマットで微妙に異なる可能性がある(例: piper-plus は声調マーカーなし、Misaki は矢印、IPA は tone letters)ため、完全な抽象化は過剰 + +ただし、今後さらに出力フォーマットが増える場合は **設計案 B** への移行を検討すべき。4つ以上のフォーマットが並立する段階が移行の判断基準となる。 + +## 7. 後続タスクへの連絡事項 + +T03(ChineseG2PEngine への ToMisaki API 追加)に伝えるべき情報: + +1. **Convert のシグネチャ**: `PinyinToMisaki.Convert(string pinyin)` と `PinyinToMisaki.Convert(string pinyin, bool includeTones)` の2つのオーバーロードが利用可能。前者は `includeTones = true` で委譲する +2. **ConvertSyllable の可視性**: `internal static` であるため、同一アセンブリ (`DotNetG2P.Chinese`) 内から直接呼び出し可能。ChineseG2PEngine の `RunPipeline` ラムダから `p => PinyinToMisaki.Convert(p)` の形で使用できる(`PinyinToIpa.Convert(p, includeTones)` と同一パターン) +3. **声調制御**: `includeTones` パラメータで声調矢印の有無を制御可能。ChineseG2PEngine の `ToMisaki(string text)` / `ToMisaki(string text, bool includeTones)` オーバーロードに対応付けること +4. **ConvertToPhonemes 未実装**: `PinyinToPiperIpa` にある `ConvertToPhonemes()` メソッド(声母と韻母を分離した配列を返す)に相当するものは T02 のスコープ外。Misaki で音素単位処理やProsody処理が必要な場合は別途タスクを起票すること +5. **バッチ API**: `ConvertBatch` 等のバッチ変換 API は T02 のスコープ外。ChineseG2PEngine 側で `BatchConversionHelper` を使用するパターン(既存の `ToIpaBatch` 等と同一)で T03 内に実装すること + +## 8. 紐づけ + +- **マイルストーン**: Mi1(Misaki 互換中国語出力) +- **依存**: T01(マッピングテーブル定義)— T01 の `s_initialMisaki`, `s_finalMisaki`, `s_toneArrows`, `s_retroflexApical`, `s_alveolarApical` が定義済みであること +- **後続**: T03(ChineseG2PEngine API 追加)— Convert メソッドが完成していることが前提 From 43504482fa6b5ce2644b9fd9002865b82110cb06 Mon Sep 17 00:00:00 2001 From: yousan Date: Sun, 12 Apr 2026 22:03:18 +0900 Subject: [PATCH 04/21] =?UTF-8?q?docs:=20T03=20ChineseG2PEngine=20ToMisaki?= =?UTF-8?q?Ipa=20API=20=E8=BF=BD=E5=8A=A0=20=E3=83=81=E3=82=B1=E3=83=83?= =?UTF-8?q?=E3=83=88=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mi2 フェーズ1枚目。PinyinToMisakiをChineseG2PEngineの公開APIに統合する タスク。RunPipelineパターンに沿って ToMisakiIpa / ToMisakiIpaBatch を 追加。既存のToPiperIPAパターンを踏襲し、BatchConversionHelperの静的 メソッド委譲パターンでアロケーションを回避する設計とした。 関連: #56 --- docs/tickets/T03-engine-api-integration.md | 429 +++++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 docs/tickets/T03-engine-api-integration.md diff --git a/docs/tickets/T03-engine-api-integration.md b/docs/tickets/T03-engine-api-integration.md new file mode 100644 index 0000000..bcb5f50 --- /dev/null +++ b/docs/tickets/T03-engine-api-integration.md @@ -0,0 +1,429 @@ +--- +ticket: T03 +title: ChineseG2PEngine ToMisakiIpa API 追加 +milestone: Mi2 +status: 未着手 +depends_on: [T02] +blocks: [T04] +--- + +# T03: ChineseG2PEngine ToMisakiIpa API 追加 + +## 1. タスク目的とゴール + +ChineseG2PEngine に Misaki 互換 IPA 出力 API を追加し、ユーザーが以下のように呼び出し可能にする。 + +```csharp +var engine = new ChineseG2PEngine(); +string ipa = engine.ToMisakiIpa("你好"); +// => Misaki互換のIPA文字列が返る + +string ipaNoTones = engine.ToMisakiIpa("你好", includeTones: false); +// => 声調なしMisaki互換IPA + +var batch = engine.ToMisakiIpaBatch(new[] { "你好", "世界" }); +// => 複数テキストの一括変換 +``` + +T01-T02 で実装済みの `PinyinToMisaki.cs`(`Conversion/` 配下の内部静的クラス)を利用し、既存の piper-plus 互換 IPA (`ToPiperIPA`) と同一のパイプラインパターンで公開 API を追加する。 + +## 2. 実装する内容の詳細 + +### 2.1 既存パターンの分析 + +ChineseG2PEngine は以下の共通パイプラインで全出力形式を統一している。 + +#### RunPipeline パターン(文字列出力) + +```csharp +private string RunPipeline(string text, Func converter) +{ + ThrowIfDisposed(); + + if (string.IsNullOrWhiteSpace(text)) + return ""; + + var entries = CollectPinyins(text); // Step1: ピンイン収集 + + if (_options.EnableToneSandhi) + ApplyToneSandhiToEntries(entries); // Step2: 声調変調 + + return FormatOutput(entries, converter); // Step3: コンバータ適用 + 文字列整形 +} +``` + +`FormatOutput` は `_options.Separator` を使って音節間を区切る。各ピンインエントリに対して `converter(entry.Pinyin)` を呼び出す。 + +#### ToPiperIPA の実装例(参考実装) + +```csharp +// 単一テキスト: RunPipelineにラムダでコンバータを渡す +public string ToPiperIPA(string text) +{ + return RunPipeline(text, p => PinyinToPiperIpa.Convert(p)); +} + +// バッチ: ThrowIfDisposed() + BatchConversionHelper.ConvertToList +public IReadOnlyList ToPiperIPABatch(string[] texts) +{ + ThrowIfDisposed(); + return BatchConversionHelper.ConvertToList(texts, ToPiperIPA); +} +``` + +#### ToIPA の実装例(includeTonesパラメータ付き参考実装) + +```csharp +// 声調あり(デフォルト) +public string ToIPA(string text) +{ + return ToIPA(text, true); +} + +// 声調制御付き: RunPipelineにクロージャでincludeTonesをキャプチャ +public string ToIPA(string text, bool includeTones) +{ + return RunPipeline(text, p => PinyinToIpa.Convert(p, includeTones)); +} + +// バッチ(声調あり): 単純委譲 +public IReadOnlyList ToIPABatch(string[] texts) +{ + ThrowIfDisposed(); + return BatchConversionHelper.ConvertToList(texts, ToIPA); +} + +// バッチ(声調制御付き): ConvertToListの3引数オーバーロードで静的メソッド委譲 +public IReadOnlyList ToIPABatch(string[] texts, bool includeTones) +{ + ThrowIfDisposed(); + return BatchConversionHelper.ConvertToList( + texts, + this, + includeTones, + ConvertIpaBatchItem); +} + +// 静的バッチアイテム変換(ラムダのアロケーション回避) +private static string ConvertIpaBatchItem(ChineseG2PEngine engine, string text, bool includeTones) +{ + return engine.ToIPA(text, includeTones); +} +``` + +### 2.2 追加する API メソッド + +以下の4メソッドを `ChineseG2PEngine.cs` に追加する。配置場所は piper-plus 互換 IPA セクションの直後(PUA出力セクションの前)に新しいセクションコメントとともに挿入する。 + +#### 2.2.1 ToMisakiIpa(string text) — 声調付きデフォルト + +```csharp +// ===================================================================== +// Misaki 互換 IPA 出力 +// ===================================================================== + +/// +/// テキストを Misaki 互換 IPA 文字列に変換する(声調マーカー付き)。 +/// +/// 入力テキスト +/// Misaki 互換 IPA 文字列 +public string ToMisakiIpa(string text) +{ + return ToMisakiIpa(text, true); +} +``` + +#### 2.2.2 ToMisakiIpa(string text, bool includeTones) — 声調制御付き + +```csharp +/// +/// テキストを Misaki 互換 IPA 文字列に変換する。 +/// +/// 入力テキスト +/// 声調マーカーを含めるかどうか +/// Misaki 互換 IPA 文字列 +public string ToMisakiIpa(string text, bool includeTones) +{ + return RunPipeline(text, p => PinyinToMisaki.Convert(p, includeTones)); +} +``` + +**ポイント**: `RunPipeline` にラムダを渡すパターンは `ToIPA(text, includeTones)` と完全に同一。`PinyinToMisaki.Convert` は T01-T02 で実装済みの `PinyinToMisaki` 静的クラスの変換メソッドで、シグネチャは `public static string Convert(string pinyin, bool includeTones)` を想定する。 + +#### 2.2.3 ToMisakiIpaBatch(string[] texts) — バッチ(声調付きデフォルト) + +```csharp +/// +/// 複数テキストを一括で Misaki 互換 IPA に変換する(声調マーカー付き)。 +/// +/// 入力テキストの配列 +/// 各テキストに対応する Misaki 互換 IPA 文字列のリスト +public IReadOnlyList ToMisakiIpaBatch(string[] texts) +{ + ThrowIfDisposed(); + return BatchConversionHelper.ConvertToList(texts, ToMisakiIpa); +} +``` + +**ポイント**: `ToPiperIPABatch` と同一パターン。`ThrowIfDisposed()` の後、`BatchConversionHelper.ConvertToList` にメソッドグループ `ToMisakiIpa`(引数1つのオーバーロード)を渡す。`ConvertToList(IReadOnlyList, Func)` オーバーロードが使われる。 + +#### 2.2.4 ToMisakiIpaBatch(string[] texts, bool includeTones) — バッチ(声調制御付き) + +```csharp +/// +/// 複数テキストを一括で Misaki 互換 IPA に変換する。 +/// +/// 入力テキストの配列 +/// 声調マーカーを含めるかどうか +/// 各テキストに対応する Misaki 互換 IPA 文字列のリスト +public IReadOnlyList ToMisakiIpaBatch(string[] texts, bool includeTones) +{ + ThrowIfDisposed(); + return BatchConversionHelper.ConvertToList( + texts, + this, + includeTones, + ConvertMisakiIpaBatchItem); +} +``` + +#### 2.2.5 静的バッチヘルパーメソッド + +ファイル末尾のバッチヘルパー領域(`ConvertIpaBatchItem` 等が並んでいる箇所)に追加する。 + +```csharp +private static string ConvertMisakiIpaBatchItem(ChineseG2PEngine engine, string text, bool includeTones) +{ + return engine.ToMisakiIpa(text, includeTones); +} +``` + +**ポイント**: `ConvertIpaBatchItem`/`ConvertZhuyinBatchItem` と同一パターン。`BatchConversionHelper.ConvertToList` オーバーロードに渡す静的メソッドで、ラムダのデリゲートアロケーションを回避する設計。 + +### 2.3 PinyinToMisaki.Convert の想定シグネチャ + +T01-T02 で `src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs` に実装済みのクラス。以下のシグネチャを前提とする。 + +```csharp +namespace DotNetG2P.Chinese.Conversion // ← 既存の PinyinToPiperIpa と同じ名前空間 +{ + internal static class PinyinToMisaki + { + /// + /// ピンイン文字列をMisaki互換IPAに変換する。 + /// + /// 声調記号付きまたは声調数字付きのピンイン文字列。 + /// 声調マーカーを含めるかどうか。 + /// Misaki互換IPA表記文字列。 + public static string Convert(string pinyin, bool includeTones); + } +} +``` + +もし T02 の実装で `Convert` メソッドのシグネチャが `Convert(string pinyin)` のみ(声調制御なし)の場合は、`includeTones` パラメータ付きオーバーロードの追加を T02 の実装者に依頼すること。 + +### 2.4 using ディレクティブの追加 + +`ChineseG2PEngine.cs` の先頭で `DotNetG2P.Chinese.Conversion` は既に `using` されている(`PinyinToPiperIpa` 等を使用)。`PinyinToMisaki` は同じ名前空間に配置するため、追加の `using` は不要。 + +```csharp +using DotNetG2P.Chinese.Conversion; // ← 既存。PinyinToMisaki もここに含まれる +``` + +### 2.5 挿入位置の詳細 + +`ChineseG2PEngine.cs` のセクション構成に従い、以下の位置に挿入する。 + +``` +// ===================================================================== +// piper-plus 互換 IPA 出力 (既存: L248-L291) +// ===================================================================== + ToPiperIPA(string text) + ToPiperIpaPhonemes(string text) + +// ===================================================================== +// Misaki 互換 IPA 出力 ← ★ここに新セクションを挿入 +// ===================================================================== + ToMisakiIpa(string text) + ToMisakiIpa(string text, bool includeTones) + +// ===================================================================== +// PUA 出力 (既存: L295-L350) +// ===================================================================== + ToPuaPhonemes(string text) + ToPuaString(string text) +``` + +バッチ API セクション内では、`ToPiperIPABatch` の直後に `ToMisakiIpaBatch` 2メソッドを追加する。 + +静的バッチヘルパーは `ConvertIpaWithProsodyBatchItem` の直後に `ConvertMisakiIpaBatchItem` を追加する。 + +## 3. 実装するために必要なエージェントチームの役割と人数 + +| 役割 | 人数 | 担当内容 | +|------|------|----------| +| 実装エージェント | 1名 | ChineseG2PEngine.cs への4メソッド + 1静的ヘルパーの追加。コンパイル確認 | +| レビューエージェント | 1名 | API 一貫性(命名規則、XMLDoc、Dispose チェック)、既存パターンとの整合性確認 | + +合計: 2名 + +T01-T02 が完了した `PinyinToMisaki.cs` を前提とするため、実装自体は既存パターンの踏襲であり、作業量は小規模。1名の実装エージェントで十分対応可能。 + +## 4. 提供範囲とテスト項目 + +### 4.1 API 統合テスト(パイプライン全体通し) + +以下のテストを `tests/DotNetG2P.Tests/` 配下に追加する(具体的なテストクラス名・配置は T04 で決定)。 + +| テスト項目 | 内容 | 検証ポイント | +|------------|------|-------------| +| 基本変換 | `engine.ToMisakiIpa("你好")` が空でない Misaki 互換 IPA を返す | パイプライン全体が接続されている | +| 声調付き | `engine.ToMisakiIpa("你好", true)` に声調マーカーが含まれる | includeTones=true の動作 | +| 声調なし | `engine.ToMisakiIpa("你好", false)` に声調マーカーが含まれない | includeTones=false の動作 | +| デフォルト声調 | `ToMisakiIpa(text)` と `ToMisakiIpa(text, true)` が同一結果 | デフォルト引数の一貫性 | +| 声調変調 | 三声連続("你好")で声調変調が適用される | EnableToneSandhi 連携 | +| 句読点区切り | `"你好,世界"` で句読点前後が正しく区切られる | FormatOutput の区切り処理 | +| 空文字入力 | `ToMisakiIpa("")` → `""` | 空入力ガード | +| null入力 | `ToMisakiIpa(null)` → `""` | null ガード(RunPipeline の IsNullOrWhiteSpace) | +| ASCII混在 | `"Hello你好"` で ASCII 部分がそのまま、漢字部分が IPA | 非漢字スルー | +| Separator 設定 | `Separator = "-"` のオプションで区切り文字が変わる | Options.Separator の反映 | + +### 4.2 バッチ API テスト + +| テスト項目 | 内容 | 検証ポイント | +|------------|------|-------------| +| バッチ基本 | `ToMisakiIpaBatch(new[] {"你好", "世界"})` が2要素を返す | バッチ変換の動作 | +| バッチ声調制御 | `ToMisakiIpaBatch(texts, false)` で全要素が声調なし | includeTones バッチ転送 | +| バッチ空配列 | `ToMisakiIpaBatch(Array.Empty())` が空リスト | 空配列ガード | +| バッチ null | `ToMisakiIpaBatch(null)` で `ArgumentNullException` | BatchConversionHelper の null チェック | + +### 4.3 異常系テスト + +| テスト項目 | 内容 | 検証ポイント | +|------------|------|-------------| +| Dispose 後呼び出し | `engine.Dispose(); engine.ToMisakiIpa("你好")` → `ObjectDisposedException` | ThrowIfDisposed | +| Dispose 後バッチ | `engine.Dispose(); engine.ToMisakiIpaBatch(texts)` → `ObjectDisposedException` | バッチ側の ThrowIfDisposed | + +## 5. 実装に関する懸念事項とレビュー項目 + +### 5.1 Dispose 済みチェック + +- `ToMisakiIpa(string)` は `RunPipeline` 内で `ThrowIfDisposed()` が呼ばれるため、明示的なチェックは不要 +- `ToMisakiIpa(string, bool)` も同様に `RunPipeline` 経由で保護される +- `ToMisakiIpaBatch` は `RunPipeline` の前に `ThrowIfDisposed()` を明示的に呼ぶ(既存バッチ API と同一パターン)。これは `BatchConversionHelper.ConvertToList` が内部で個別に `ToMisakiIpa` を呼ぶ前にまずエンジンの状態を検証するため +- レビュー時に `ThrowIfDisposed()` の呼び出し漏れがないことを確認すること + +### 5.2 スレッドセーフティ + +- `ChineseG2PEngine` は「辞書はコンストラクタで読み込まれ、以後は読み取り専用」と XMLDoc に明記されている +- `RunPipeline` 内の `CollectPinyins` / `FormatOutput` はローカル変数のみ使用し、インスタンス状態を変更しない +- `PinyinToMisaki.Convert` は静的メソッドかつステートレスである前提(T02 の実装を確認すること) +- `_disposed` フィールドは `Volatile.Read` / `Interlocked.CompareExchange` で安全にアクセスされている +- **レビュー項目**: `PinyinToMisaki` 内に static mutable state(`static Dictionary` への遅延書き込み等)がないことを確認する + +### 5.3 Separator 設定との関係 + +- `RunPipeline` → `FormatOutput` は `_options.Separator` を使って音節間を区切る +- Misaki 互換出力で特別な区切り規則が必要な場合(例: Misaki ではスペース区切りではなく連結する等)、`RunPipeline` ではなく独自の `FormatOutput` 相当を実装する必要がある +- **レビュー項目**: Misaki のフォーマット仕様を確認し、`_options.Separator` をそのまま使って問題ないかを T02 実装者に確認すること。問題がある場合は `RunPipeline` を使わず `ToPiperIpaPhonemes` のように `CollectPinyins` → `ApplyToneSandhiToEntries` → 独自ループの直接実装を検討する + +### 5.4 命名規則の一貫性 + +- 既存: `ToPiperIPA`("IPA" 全大文字)、`ToIPA`(全大文字) +- 新規: `ToMisakiIpa` — "Ipa" を PascalCase にする理由は、"Misaki" が固有名詞であり "MisakiIPA" だと "KIPA" のように読めてしまうため。ただし、既存の `ToPiperIPA` との整合性から `ToMisakiIPA` も検討すべき +- **レビュー項目**: チーム内で `ToMisakiIpa` vs `ToMisakiIPA` の命名を統一すること。本チケットでは Issue #56 の記載に従い `ToMisakiIpa` を採用する + +### 5.5 PinyinToMisaki.Convert のシグネチャ互換性 + +- 本チケットは `Convert(string pinyin, bool includeTones)` を前提としている +- T02 の実装で `Convert(string pinyin)` のみの場合、以下の対応が必要: + - (A) T02 に `includeTones` 付きオーバーロードの追加を依頼する(推奨) + - (B) ChineseG2PEngine 側で `includeTones=false` 時に声調マーカーを除去する後処理を追加する(非推奨: 責務の分離に反する) + +## 6. 一から作り直すとしたら + +### 6.1 メソッド爆発問題 + +現在の ChineseG2PEngine は出力形式ごとに専用メソッドが増殖している: + +``` +ToPinyin / ToPinyinList / ToPinyinBatch / ToPinyinListBatch (各2オーバーロード) +ToIPA / ToIPABatch (各2オーバーロード) +ToZhuyin / ToZhuyinBatch (各2オーバーロード) +ToPiperIPA / ToPiperIpaPhonemes / ToPiperIPABatch +ToPuaPhonemes / ToPuaString / ToPuaStringBatch +ToIpaWithProsody / ToIpaWithProsodyBatch (各2オーバーロード) +ToMisakiIpa / ToMisakiIpaBatch (各2オーバーロード) ← 今回追加 +``` + +合計 30 メソッド以上のフラットな API サーフェスとなり、今後さらに出力形式が増えると管理が困難になる。 + +### 6.2 戦略パターンによる統一設計 + +出力形式を `IChineseOutputFormat` インターフェースで抽象化する: + +```csharp +public interface IChineseOutputFormat +{ + TResult Convert(string pinyin, PinyinSyllable syllable); +} + +// 各形式を個別のクラスとして実装 +public sealed class MisakiIpaFormat : IChineseOutputFormat { ... } +public sealed class PiperIpaFormat : IChineseOutputFormat { ... } +public sealed class ZhuyinFormat : IChineseOutputFormat { ... } +``` + +エンジン側は汎用メソッド1つで対応: + +```csharp +public string Convert(string text) where TFormat : IChineseOutputFormat, new() +{ + return RunPipeline(text, new TFormat().Convert); +} +``` + +**利点**: 新形式追加時にエンジンクラスの変更が不要(Open-Closed Principle)。 +**欠点**: ジェネリクスが .NET Standard 2.1 の型制約に縛られる。Unity IL2CPP との相性問題の可能性。 + +### 6.3 ビルダーパターンによるフルエント API + +```csharp +var result = engine.Convert("你好") + .ToFormat(OutputFormat.MisakiIpa) + .WithTones(true) + .WithSeparator(" ") + .Execute(); +``` + +**利点**: メソッド数が爆発しない。オプションの組み合わせを柔軟に表現可能。 +**欠点**: 中間オブジェクトのアロケーションが発生する。既存 API との後方互換性を維持しながらの導入が複雑。 + +### 6.4 現実的な判断 + +現時点では既存パターンの踏襲(専用メソッド追加)が最も安全。理由: +- 既存ユーザーとの後方互換性を維持できる +- Unity IL2CPP 環境でのジェネリクス問題を回避できる +- 各メソッドの IntelliSense / XMLDoc が明確 +- 出力形式の総数は有限(現実的に 10 種類程度が上限) + +ただし、将来的に出力形式が 10 種類を超える場合は、戦略パターンへのリファクタリングを検討すべき。その際は既存メソッドを `[Obsolete]` にせず、内部で戦略パターンに委譲する形にすれば後方互換性を維持できる。 + +## 7. 後続タスクへの連絡事項 + +T04(テスト実装)担当者への伝達事項: + +1. **テストクラスの配置**: 既存の `ChineseG2PEngineTests` に追加するか、`ChineseG2PMisakiTests` として分離するかはテストファイルの規模に応じて判断する。既存テストファイルのパターンを確認すること +2. **PinyinToMisaki 単体テスト**: 本チケット(T03)は API 統合のみ。`PinyinToMisaki.Convert` の単体テスト(個別ピンイン→IPA 変換の正確性)は T02 のテストスコープに含まれるが、T04 でも統合テストとして通しで検証すること +3. **期待値の取得方法**: Misaki 互換 IPA の期待値は、Python の misaki ライブラリの出力と比較して決定する。T02 の実装で参照した変換テーブルに基づいて期待値を作成すること +4. **声調変調の検証**: `"你好"` は三声連続(nǐ + hǎo)で声調変調が適用される(nǐ → ní)。声調変調後の Misaki IPA 出力が正しいことを検証するテストを必ず含めること +5. **Separator テスト**: `ChineseG2POptions` の `Separator` プロパティが Misaki 出力にも反映されることを確認するテストを含めること。デフォルト(スペース区切り)と カスタム区切り文字の両方をテストする +6. **Dispose テスト**: `ObjectDisposedException` のテストは `Assert.Throws` で検証する。単一 API とバッチ API の両方で確認すること +7. **バッチ API の戻り値型**: `ToMisakiIpaBatch` の戻り値は `IReadOnlyList` であることに注意。`List` や `string[]` ではない + +## 8. 紐づけ + +- **マイルストーン**: Mi2(Misaki 互換中国語出力) +- **依存**: T02(PinyinToMisaki.cs の実装完了が前提) +- **後続**: T04(テスト実装 -- 本チケットの API に対する統合テスト・異常系テスト) +- **関連 Issue**: #56(Misaki 互換中国語出力の要望) From a8c9242f8d4b7d1b91fc71c1bd82875784da42ec Mon Sep 17 00:00:00 2001 From: yousan Date: Sun, 12 Apr 2026 22:04:01 +0900 Subject: [PATCH 05/21] =?UTF-8?q?docs:=20T04=20Misaki=E4=BA=92=E6=8F=9B?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E5=AE=9F=E8=A3=85=20=E3=83=81?= =?UTF-8?q?=E3=82=B1=E3=83=83=E3=83=88=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mi2 フェーズ2枚目。ChineseMisakiIpaTests.cs を新規作成し、声調マッピング、 声母マッピング、韻母マッピング、そり舌/歯茎母音、声調変調、エッジケース、 Issue #56再現、バッチAPI、Dispose後、他API比較、回帰確認の12カテゴリ 約105件のテストを実装するタスク。既存 ChinesePiperIpaTests.cs の パターンを踏襲し、E2Eテスト中心の方針とした。 関連: #56 --- docs/tickets/T04-misaki-tests.md | 599 +++++++++++++++++++++++++++++++ 1 file changed, 599 insertions(+) create mode 100644 docs/tickets/T04-misaki-tests.md diff --git a/docs/tickets/T04-misaki-tests.md b/docs/tickets/T04-misaki-tests.md new file mode 100644 index 0000000..3c75f97 --- /dev/null +++ b/docs/tickets/T04-misaki-tests.md @@ -0,0 +1,599 @@ +--- +ticket: T04 +title: Misaki互換テスト実装 +milestone: Mi2 +status: 未着手 +depends_on: [T03] +blocks: [T05] +--- + +# T04 — Misaki互換テスト実装 + +## 1. タスク目的とゴール + +### 目的 +Mi2マイルストーンの2枚目として、T01-T03で実装された `PinyinToMisaki` 変換クラスおよび `ChineseG2PEngine.ToMisakiIpa()` / `ToMisakiIpaBatch()` API の品質を、網羅的な単体テストとエンドツーエンドテストで保証する。 + +### ゴール +1. **Misaki互換出力の正確性保証**: 声調マッピング(矢印記号)、声母マッピング(`j/q→ʨ/ʨʰ`, `z/c→ʦ/ʦʰ`)、韻母マッピング(二重母音の非音節化符号 `i̯/u̯`)がすべて仕様通りに出力されること +2. **既存936件テストへの回帰なし**: Misaki対応の変更が既存の `ToIPA` / `ToPiperIPA` / `ToZhuyin` 出力に影響を与えていないことを確認 +3. **Issue #56 再現**: `"你好"` の出力が Misaki と同等の `ni↗xau̯↓` 形式で得られることを確認 +4. **エッジケース網羅**: 空文字列・null・句読点のみ・英数字混在・サロゲートペア・er化音・軽声などで例外を投げず、期待通りの挙動を示すこと +5. **パイプライン連携確認**: 声調変調(三声連読、一/不の変調)の結果が Misaki 出力にも正しく反映されること + +### 成功指標 +- `ChineseMisakiIpaTests` が 100% パスする(最低35件以上のテストを想定) +- 既存 `dotnet test DotNetG2P.slnx` が全件パスする(936件 + 新規テスト) +- `ToMisakiIpa("你好")` が Misaki と同等の出力を返す +- 全エッジケースで例外が発生しない + +--- + +## 2. 実装する内容の詳細 + +### 2-1. テストファイルの構成判断 + +| 方針 | 判断 | +|------|------| +| **単一ファイル集約**: `ChineseMisakiIpaTests.cs` にすべてのテストを配置 | **採用** | +| **分離**: `ChineseMisakiIpaTests.cs`(単体)+ `ChineseMisakiIntegrationTests.cs`(統合) | **不採用**(初期対応ではオーバーエンジニアリング) | +| **さらに分離**: `ChineseMisakiEdgeCaseTests.cs` の追加 | **Mi3 で検討**(マイルストーン Mi3 に記載済み) | + +**採用理由**: `ChinesePiperIpaTests.cs`(512行)が単一ファイルで全カテゴリのテストを含んでいる先例に従う。保守性・レビュー効率・ファイル検索の観点で単一ファイル集約が最適。将来エッジケースが肥大化した場合のみ Mi3 で `ChineseMisakiEdgeCaseTests.cs` に分離する。 + +### 2-2. テストクラス構成 + +```csharp +namespace DotNetG2P.Tests.ChineseG2P +{ + /// + /// Misaki (Kokoro TTS) 互換 IPA 変換の正確性を検証するテスト。 + /// ChineseG2PEngine の ToMisakiIpa() メソッド経由で、 + /// Misaki方式の声母・韻母IPAマッピング、特殊母音、声調矢印記号を検証する。 + /// + public class ChineseMisakiIpaTests : IDisposable + { + private readonly ChineseG2PEngine _engine; + + public ChineseMisakiIpaTests() + { + _engine = new ChineseG2PEngine(); + } + + public void Dispose() + { + _engine.Dispose(); + } + + // セクション 1 - 13(後述) + } +} +``` + +### 2-3. 各テストメソッド一覧(カテゴリ別) + +#### セクション 1: 声調矢印マッピング(1-4声 + 軽声) + +| # | メソッド名 | 入力 | 期待出力/Assert | 備考 | +|---|----------|------|---------------|------| +| 1.1 | `ToMisakiIpa_第1声_矢印右向き` | `"妈"` (mā) | `"ma\u2192"` (`ma→`) | `Assert.Equal` 完全一致 | +| 1.2 | `ToMisakiIpa_第2声_矢印右上向き` | `"麻"` (má) | `"ma\u2197"` (`ma↗`) | `Assert.Equal` 完全一致 | +| 1.3 | `ToMisakiIpa_第3声_矢印下向き` | `"马"` (mǎ, 単字で変調なし) | `"ma\u2193"` (`ma↓`) | `Assert.Equal` 完全一致 | +| 1.4 | `ToMisakiIpa_第4声_矢印右下向き` | `"骂"` (mà) | `"ma\u2198"` (`ma↘`) | `Assert.Equal` 完全一致 | +| 1.5 | `ToMisakiIpa_軽声_矢印なし` | `"吗"` (ma, 軽声) | `"ma"`(矢印なし) | `Assert.DoesNotContain` で各矢印を検証 | +| 1.6 | `ToMisakiIpa_IncludeTonesFalse_矢印なし` | `"妈"`, `includeTones=false` | `"ma"`(矢印なし) | `Assert.Equal` | +| 1.7 | `ToMisakiIpa_IPA声調letterを含まない` (Theory) | `"妈麻马骂"` 各1字 | `˥ ˦ ˧ ˨ ˩` を含まない | `Assert.DoesNotContain` 5回 | + +**Theory 例:** +```csharp +[Theory] +[InlineData("\u5988", "ma\u2192")] // 妈 → ma→ +[InlineData("\u9EBB", "ma\u2197")] // 麻 → ma↗ +[InlineData("\u9A6C", "ma\u2193")] // 马 → ma↓ +[InlineData("\u9A82", "ma\u2198")] // 骂 → ma↘ +public void ToMisakiIpa_声調マッピング網羅(string hanzi, string expected) +{ + var result = _engine.ToMisakiIpa(hanzi); + Assert.Equal(expected, result); +} +``` + +#### セクション 2: 声母マッピング(Misaki固有差異) + +| # | メソッド名 | 入力 | 期待Assert | 備考 | +|---|----------|------|-----------|------| +| 2.1 | `ToMisakiIpa_j声母_ʨを返す` | `"几"` (jǐ) | `Assert.Contains("\uA7B3", result)` ではなく `"\u02A8"` (ʨ, U+02A8) | Misaki固有: `tɕ` → `ʨ` | +| 2.2 | `ToMisakiIpa_q声母_ʨʰを返す` | `"七"` (qī) | `Assert.Contains("\u02A8\u02B0", result)` (ʨʰ) | Misaki固有: `tɕʰ` → `ʨʰ` | +| 2.3 | `ToMisakiIpa_x声母_ɕを返す` | `"西"` (xī) | `Assert.Contains("\u0255", result)` (ɕ) | 標準IPAと共通 | +| 2.4 | `ToMisakiIpa_z声母_ʦを返す` | `"在"` (zài) | `Assert.Contains("\u02A6", result)` (ʦ, U+02A6) | Misaki固有: `ts` → `ʦ` | +| 2.5 | `ToMisakiIpa_c声母_ʦʰを返す` | `"才"` (cái) | `Assert.Contains("\u02A6\u02B0", result)` (ʦʰ) | Misaki固有: `tsʰ` → `ʦʰ` | +| 2.6 | `ToMisakiIpa_s声母_sを返す` | `"三"` (sān) | `Assert.Contains("s", result)` | 標準IPAと共通 | +| 2.7 | `ToMisakiIpa_zh声母_ʈʂを返す` | `"知"` (zhī) | `Assert.Contains("\u0288\u0282", result)` (ʈʂ) | 標準IPAと同じ | +| 2.8 | `ToMisakiIpa_ch声母_ʈʂʰを返す` | `"吃"` (chī) | `Assert.Contains("\u0288\u0282\u02B0", result)` (ʈʂʰ) | 標準IPAと同じ | + +**Theory による声母網羅:** +```csharp +[Theory] +[InlineData("\u51E0", "\u02A8")] // 几 (jǐ): j → ʨ (Misaki固有) +[InlineData("\u4E03", "\u02A8\u02B0")] // 七 (qī): q → ʨʰ (Misaki固有) +[InlineData("\u897F", "\u0255")] // 西 (xī): x → ɕ (共通) +[InlineData("\u5728", "\u02A6")] // 在 (zài): z → ʦ (Misaki固有) +[InlineData("\u624D", "\u02A6\u02B0")] // 才 (cái): c → ʦʰ (Misaki固有) +[InlineData("\u4E09", "s")] // 三 (sān): s → s (共通) +[InlineData("\u5988", "m")] // 妈 (mā): m → m (共通) +[InlineData("\u7238", "p")] // 爸 (bà): b → p (共通) +[InlineData("\u6015", "p\u02B0")] // 怕 (pà): p → pʰ (共通) +[InlineData("\u98DE", "f")] // 飞 (fēi): f → f (共通) +[InlineData("\u5927", "t")] // 大 (dà): d → t (共通) +[InlineData("\u5929", "t\u02B0")] // 天 (tiān): t → tʰ (共通) +[InlineData("\u5973", "n")] // 女 (nǚ): n → n (共通) +[InlineData("\u6765", "l")] // 来 (lái): l → l (共通) +[InlineData("\u5E72", "k")] // 干 (gān): g → k (共通) +[InlineData("\u770B", "k\u02B0")] // 看 (kàn): k → kʰ (共通) +[InlineData("\u597D", "x")] // 好 (hǎo): h → x (共通) +public void ToMisakiIpa_声母マッピング網羅(string hanzi, string expectedInitialIpa) +{ + var result = _engine.ToMisakiIpa(hanzi); + Assert.Contains(expectedInitialIpa, result); +} +``` + +#### セクション 3: 韻母マッピング(二重母音の非音節化符号) + +非音節化符号は `U+032F` (combining inverted breve below)。 + +| # | メソッド名 | 入力 | 期待Assert | 備考 | +|---|----------|------|-----------|------| +| 3.1 | `ToMisakiIpa_ai韻母_i非音節化を返す` | `"爱"` (ài) | `Assert.Contains("ai\u032F", result)` | DotNetG2P `aɪ` → Misaki `ai̯` | +| 3.2 | `ToMisakiIpa_ei韻母_i非音節化を返す` | `"北"` (běi) | `Assert.Contains("ei\u032F", result)` | DotNetG2P `eɪ` → Misaki `ei̯` | +| 3.3 | `ToMisakiIpa_ao韻母_u非音節化を返す` | `"好"` (hǎo, 単字) | `Assert.Contains("au\u032F", result)` | DotNetG2P `aʊ` → Misaki `au̯` | +| 3.4 | `ToMisakiIpa_ou韻母_u非音節化を返す` | `"走"` (zǒu) | `Assert.Contains("ou\u032F", result)` | DotNetG2P `oʊ` → Misaki `ou̯` | +| 3.5 | `ToMisakiIpa_iao韻母_u非音節化を返す` | `"小"` (xiǎo) | `Assert.Contains("iau\u032F", result)` | 3母音韻母 | +| 3.6 | `ToMisakiIpa_iu韻母_iou非音節化を返す` | `"六"` (liù) | `Assert.Contains("iou\u032F", result)` | DotNetG2P `ioʊ` → Misaki `iou̯` | +| 3.7 | `ToMisakiIpa_uai韻母_非音節化を返す` | `"怀"` (huái) | `Assert.Contains("uai\u032F", result)` | 3母音韻母 | +| 3.8 | `ToMisakiIpa_ui韻母_uei非音節化を返す` | `"对"` (duì) | `Assert.Contains("uei\u032F", result)` | DotNetG2P `ueɪ` → Misaki `uei̯` | +| 3.9 | `ToMisakiIpa_単母音a_非音節化符号なし` | `"啊"` (ā) | `Assert.DoesNotContain("\u032F", result)` | 単母音には付かないこと | + +**Theory での網羅:** +```csharp +[Theory] +[InlineData("\u7231", "ai\u032F")] // 爱 (ài) +[InlineData("\u5317", "ei\u032F")] // 北 (běi) +[InlineData("\u597D", "au\u032F")] // 好 (hǎo) ※ 単字の場合 +[InlineData("\u8D70", "ou\u032F")] // 走 (zǒu) +[InlineData("\u5C0F", "iau\u032F")] // 小 (xiǎo) +[InlineData("\u516D", "iou\u032F")] // 六 (liù) +[InlineData("\u6000", "uai\u032F")] // 怀 (huái) +[InlineData("\u5BF9", "uei\u032F")] // 对 (duì) +public void ToMisakiIpa_二重母音非音節化符号マッピング(string hanzi, string expectedFinal) +{ + var result = _engine.ToMisakiIpa(hanzi); + Assert.Contains(expectedFinal, result); +} +``` + +#### セクション 4: そり舌/歯茎母音 + +Misaki 設計ドキュメントより: `zh/ch/sh/r+i → ɻ̩` / `ʐ̩`, `z/c/s+i → ɹ̩` / `z̩` + +初期実装では両方とも単一表現(`ɻ̩` / `ɹ̩`)を採用する可能性が高いため、それに準拠。 + +| # | メソッド名 | 入力 | 期待Assert | 備考 | +|---|----------|------|-----------|------| +| 4.1 | `ToMisakiIpa_zi_歯茎母音を含む` | `"子"` (zǐ) | `Assert.Contains("\u0279\u0329", result)` (ɹ̩) | z+i | +| 4.2 | `ToMisakiIpa_ci_歯茎母音を含む` | `"次"` (cì) | `Assert.Contains("\u0279\u0329", result)` (ɹ̩) | c+i | +| 4.3 | `ToMisakiIpa_si_歯茎母音を含む` | `"四"` (sì) | `Assert.Contains("\u0279\u0329", result)` (ɹ̩) | s+i | +| 4.4 | `ToMisakiIpa_zhi_そり舌母音を含む` | `"知"` (zhī) | `Assert.Contains("\u027B\u0329", result)` (ɻ̩) | zh+i | +| 4.5 | `ToMisakiIpa_chi_そり舌母音を含む` | `"吃"` (chī) | `Assert.Contains("\u027B\u0329", result)` (ɻ̩) | ch+i | +| 4.6 | `ToMisakiIpa_shi_そり舌母音を含む` | `"十"` (shí) | `Assert.Contains("\u027B\u0329", result)` (ɻ̩) | sh+i | +| 4.7 | `ToMisakiIpa_ri_そり舌母音を含む` | `"日"` (rì) | `Assert.Contains("\u027B\u0329", result)` (ɻ̩) | r+i | + +#### セクション 5: 声調変調(三声連読、一/不変調) + +| # | メソッド名 | 入力 | 期待Assert | 備考 | +|---|----------|------|-----------|------| +| 5.1 | `ToMisakiIpa_三声連読_你好_前字が二声矢印` | `"你好"` | `Assert.Contains("ni\u2197", result)` (ni↗) | 3+3 → 2+3 変調 | +| 5.2 | `ToMisakiIpa_三声連読_你好_後字が三声矢印保持` | `"你好"` | `Assert.Contains("\u2193", result)` (↓) | 後字の三声矢印は残る | +| 5.3 | `ToMisakiIpa_三声連読_你也好` | `"你也好"` | `Assert.Contains("ni\u2197", result)` + 全体が3音節 | 3+3+3 連読 | +| 5.4 | `ToMisakiIpa_一変調_一个_二声矢印` | `"一个"` | `Assert.Contains("i\u2197", result)` (i↗) | 一+4声 → 2声変調 | +| 5.5 | `ToMisakiIpa_一変調_一天_四声矢印` | `"一天"` | `Assert.Contains("i\u2198", result)` (i↘) | 一+1声 → 4声変調 | +| 5.6 | `ToMisakiIpa_不変調_不要_二声矢印` | `"不要"` | `Assert.Contains("pu\u2197", result)` (pu↗) | 不+4声 → 2声変調 | +| 5.7 | `ToMisakiIpa_EnableToneSandhiFalse_你好_三声保持` | `"你好"`, sandhi=false | `Assert.Contains("ni\u2193", result)` (ni↓) | 変調無効で3声のまま | + +#### セクション 6: エッジケース + +| # | メソッド名 | 入力 | 期待Assert | 備考 | +|---|----------|------|-----------|------| +| 6.1 | `ToMisakiIpa_null入力_空文字列` | `null` | `Assert.Equal("", result)` | null安全 | +| 6.2 | `ToMisakiIpa_空文字列_空文字列` | `""` | `Assert.Equal("", result)` | | +| 6.3 | `ToMisakiIpa_空白のみ_空文字列` | `" "` | `Assert.Equal("", result)` | | +| 6.4 | `ToMisakiIpa_タブ改行_空文字列` | `"\t\n"` | `Assert.Equal("", result)` | | +| 6.5 | `ToMisakiIpa_CJK句読点のみ_空文字列` | `",。!"` | `Assert.Equal("", result)` | | +| 6.6 | `ToMisakiIpa_数字のみ_数字パススルー` | `"123"` | `Assert.Contains("123", result)` | | +| 6.7 | `ToMisakiIpa_英数字混在_英数字パススルー` | `"OK了"` | `Assert.Contains("OK", result)` | | +| 6.8 | `ToMisakiIpa_非漢字混在_漢字部分のみ変換` | `"Hello你好"` | `Assert.Contains("Hello", result)` + `Assert.Contains("ni\u2197", result)` | | +| 6.9 | `ToMisakiIpa_er化音_儿_独立erが変換される` | `"儿"` (ér) | `Assert.NotEmpty(result)` + `Assert.Contains("\u2197", result)` (2声) | er韻母単独。Misakiでの表現は T01-T03 実装に準拠 | +| 6.10 | `ToMisakiIpa_ü母音_鱼_yが出力される` | `"鱼"` (yú) | `Assert.Contains("y", result)` | | +| 6.11 | `ToMisakiIpa_サロゲートペア_エラーなし` | `"\U00020000你好"` | `Assert.NotNull(result)` + `Assert.Contains("ni", result)` | | +| 6.12 | `ToMisakiIpa_絵文字混在_エラーなし` | `"好\U0001F600好"` | `Assert.NotEmpty(result)` | | +| 6.13 | `ToMisakiIpa_長文_全音節変換` | `"中华人民共和国"` (7字) | `Split(' ').Length == 7` | | + +#### セクション 7: Issue #56 再現テスト + +Issue #56 の要望通り `"你好"` が Misaki 互換形式で出力されることを確認。 + +| # | メソッド名 | 入力 | 期待Assert | 備考 | +|---|----------|------|-----------|------| +| 7.1 | `ToMisakiIpa_Issue56_你好_完全一致` | `"你好"` | `Assert.Equal("ni\u2197 xau\u032F\u2193", result)` | 三声連読後: ni↗ xau̯↓ | +| 7.2 | `ToMisakiIpa_Issue56_你好_声調矢印を含む` | `"你好"` | `Assert.Contains("\u2197", result)` + `Assert.Contains("\u2193", result)` | 2声矢印と3声矢印両方 | +| 7.3 | `ToMisakiIpa_Issue56_你好_IPA声調letterを含まない` | `"你好"` | `Assert.DoesNotContain("\u02E5", result)` ... 5種類 | Misakiは矢印のみ使用 | +| 7.4 | `ToMisakiIpa_Issue56_你好_非音節化符号を含む` | `"你好"` | `Assert.Contains("\u032F", result)` | au̯ の非音節化符号 | + +**注**: 7.1 の完全一致テストは T01-T03 実装完了後に期待値を実装と合わせて調整すること。Misaki Python 実装との照合が望ましい(後述の「懸念事項」参照)。 + +#### セクション 8: バッチ API テスト + +| # | メソッド名 | 入力 | 期待Assert | 備考 | +|---|----------|------|-----------|------| +| 8.1 | `ToMisakiIpaBatch_複数テキスト_正しい件数` | `["你好", "世界", "中国"]` | `Assert.Equal(3, results.Count)` | | +| 8.2 | `ToMisakiIpaBatch_各結果が非空` | `["妈", "爸"]` | `Assert.NotEmpty(r)` 各要素 | | +| 8.3 | `ToMisakiIpaBatch_個別呼び出しと同一結果` | `["东", "元", "六"]` | `Assert.Equal(individual, batchResults[i])` | 個別呼び出しと一致 | +| 8.4 | `ToMisakiIpaBatch_IncludeTonesFalse_矢印なし` | `["妈", "麻"]`, `includeTones=false` | `Assert.DoesNotContain("\u2192", r)` ... 全矢印 | | +| 8.5 | `ToMisakiIpaBatch_空配列_空リスト` | `Array.Empty()` | `Assert.Empty(results)` | | +| 8.6 | `ToMisakiIpaBatch_混在入力_全要素が返る` | `["你好", "", null!, "世界"]` | 4要素、空/nullは空文字列 | | +| 8.7 | `ToMisakiIpaBatch_Null引数_ArgumentNullException` | `null` | `Assert.Throws` | | + +#### セクション 9: Dispose後の動作 + +| # | メソッド名 | 期待Assert | 備考 | +|---|----------|-----------|------| +| 9.1 | `Dispose後_ToMisakiIpa_ObjectDisposedException` | `Assert.Throws` | | +| 9.2 | `Dispose後_ToMisakiIpa_WithTones_ObjectDisposedException` | 同上 | `includeTones` オーバーロード | +| 9.3 | `Dispose後_ToMisakiIpaBatch_ObjectDisposedException` | 同上 | | +| 9.4 | `Dispose後_ToMisakiIpaBatch_WithTones_ObjectDisposedException` | 同上 | | + +#### セクション 10: 複数文字テキスト(音節区切り確認) + +| # | メソッド名 | 入力 | 期待Assert | 備考 | +|---|----------|------|-----------|------| +| 10.1 | `ToMisakiIpa_複数漢字_スペース区切り` | `"中国"` | `Assert.Contains(" ", result)` | 音節間はスペース | +| 10.2 | `ToMisakiIpa_4文字_3スペース区切り` | `"你好世界"` | スペース数 == 3 | | +| 10.3 | `ToMisakiIpa_長文_文字数と音節数が一致` | `"我爱北京天安门"` | `Split(' ').Length == 7` | | + +#### セクション 11: 標準IPA・piper-plus との比較 + +| # | メソッド名 | 入力 | 期待Assert | 備考 | +|---|----------|------|-----------|------| +| 11.1 | `ToMisakiIpa_ToIPA出力と異なる` | `"妈"` | `Assert.NotEqual(standardIpa, misakiIpa)` | 声調記号体系が異なる | +| 11.2 | `ToMisakiIpa_ToPiperIPA出力と異なる` | `"几"` | `Assert.NotEqual(piperIpa, misakiIpa)` | j声母が異なる | +| 11.3 | `ToMisakiIpa_IncludeTonesFalse時_z声母だけがpiper-plusと異なる` | `"在"`, sandhi無関係 | piper: `ts`, misaki: `ʦ` | | + +#### セクション 12: 回帰確認(他APIへの影響なし) + +| # | メソッド名 | 期待Assert | 備考 | +|---|----------|-----------|------| +| 12.1 | `ToIPA_回帰_Misaki実装後も変わらない` | `Assert.Equal("ma\u02E5\u02E5", engine.ToIPA("妈"))` | `ma˥˥` 既存出力 | +| 12.2 | `ToPiperIPA_回帰_Misaki実装後も変わらない` | `Assert.Equal("ma", engine.ToPiperIPA("妈"))` | 既存出力 | +| 12.3 | `ToZhuyin_回帰_Misaki実装後も変わらない` | `Assert.Equal("\u3107\u311A", engine.ToZhuyin("妈"))` | ㄇㄚ 既存出力 | + +--- + +## 3. 実装するために必要なエージェントチームの役割と人数 + +| 役割 | 人数 | 担当範囲 | 所要工数目安 | +|------|------|---------|-------------| +| **テスト設計エージェント** | 1 | テストメソッドの網羅的な洗い出し、Theory入力データの設計、Unicode コードポイント検証 | 0.5日 | +| **テスト実装エージェント** | 1 | `ChineseMisakiIpaTests.cs` の実装、xUnit セマンティクス確認 | 1日 | +| **Misaki 照合エージェント** | 1 | Misaki Python 実装または論文での期待出力の調査・照合、テストデータの妥当性検証 | 0.5日 | +| **回帰テストレビュアー** | 1 | 既存936件テストへの影響確認、`dotnet test` 全件実行、差分レポート | 0.5日 | +| **レビュー担当** | 1 | コードレビュー、テストカバレッジ確認、T05 への引き継ぎ事項整理 | 0.25日 | + +**合計**: 5役割 / 5人(または1人が複数役を兼務可能)。単一エンジニアなら約2.5人日。 + +**最小構成**: テスト実装1名 + 照合1名 の2名体制。実装は「テスト設計→実装→照合→回帰確認→レビュー」のシーケンシャルな流れで可。 + +--- + +## 4. 提供範囲とテスト項目 + +### 4-1. 単体テスト(Unit Tests) + +`PinyinToMisaki.Convert` 静的メソッド単体のテスト。ただし `PinyinToMisaki` が `internal static` のため、以下のいずれかで対応する: + +| オプション | 方針 | +|----------|------| +| **A. `InternalsVisibleTo` 属性で `DotNetG2P.Tests` に公開** | 既存の `PinyinParser` などは同様のパターンを使用している前提で確認 | +| **B. E2Eテストのみで間接検証** | `ChineseG2PEngine.ToMisakiIpa()` を経由して検証する(現実的) | + +**採用**: **B 方針**(E2E 経由で検証)。理由: +- 既存 `ChinesePiperIpaTests.cs` も `PinyinToPiperIpa` を直接テストせず `ToPiperIPA()` 経由で検証している +- エンジン経由の方がリアルワールドの使用パターンに近い +- ただし、T03 実装完了後に T01-T02 で作成した `PinyinToMisaki` を直接テストする必要が生じた場合のみ A 方針に切り替え + +### 4-2. エンドツーエンドテスト(E2E Tests) + +`ChineseG2PEngine.ToMisakiIpa()` の全パイプラインを通すテスト。以下の処理を統合的に検証: + +1. 漢字入力 → フレーズ辞書/単字辞書によるピンイン解決 +2. ピンイン → 声調変調(三声連読、一/不変調) +3. ピンイン → Misaki互換IPA文字列 +4. 複数漢字のスペース区切り統合 + +### 4-3. テスト件数の目安 + +| カテゴリ | 単発テスト | Theory テスト | 合計 | +|---------|----------|--------------|------| +| 1. 声調矢印マッピング | 7 | 1 (4ケース) | 11 | +| 2. 声母マッピング | 8 | 1 (17ケース) | 25 | +| 3. 韻母マッピング | 9 | 1 (8ケース) | 17 | +| 4. そり舌/歯茎母音 | 7 | - | 7 | +| 5. 声調変調 | 7 | - | 7 | +| 6. エッジケース | 13 | 0-1 (4ケース) | 13-17 | +| 7. Issue #56 再現 | 4 | - | 4 | +| 8. バッチAPI | 7 | - | 7 | +| 9. Dispose後 | 4 | - | 4 | +| 10. 複数文字テキスト | 3 | - | 3 | +| 11. 他API比較 | 3 | - | 3 | +| 12. 回帰確認 | 3 | - | 3 | +| **合計(単発)** | **75** | **30ケース** | **約105件** | + +**最低目標**: 40件以上(上記のうち主要カテゴリ 1-8 のテストを最低限実装) +**標準目標**: 70件以上(全カテゴリをカバー) +**満点目標**: 100件以上(Theory の全ケースを含む) + +### 4-4. 回帰テスト範囲 + +既存の以下テストが100%パスすること: +- `ChineseG2PEngineC4Tests.cs`(IPA/注音/バッチ) +- `ChinesePiperIpaTests.cs`(piper-plus IPA) +- `ChinesePiperIpaComparisonTests.cs`(比較) +- `ChinesePiperEdgeCaseTests.cs`(エッジケース) +- `ChinesePiperIntegrationTests.cs`(統合) +- `ChineseAccuracyTests.cs`(精度) +- その他 `tests/DotNetG2P.Tests/ChineseG2P/*.cs` の全テスト + +**確認コマンド**: `dotnet test DotNetG2P.slnx --filter "FullyQualifiedName~ChineseG2P"` + +--- + +## 5. 実装に関する懸念事項とレビュー項目 + +### 5-1. Misaki の正確な出力との照合方法 + +**懸念**: DotNetG2P のテストで「期待出力」として何を採用するかが不明確。Misaki Python 実装を動かして出力を取得する手段が確立していない。 + +**対策**: + +| 方法 | 実現性 | 備考 | +|------|-------|------| +| A. Misaki Python 実装をローカル実行して出力を取得 | 中 | Python 環境構築が必要。CI に組み込めないので参考値として使用 | +| B. Misaki の README や論文記載の例を参照 | 高 | 量が限られるが信頼性は高い(例: `你好` → `ni↓xau̯↓`※sandhi無効時) | +| C. Kokoro TTS のテストデータから逆引き | 低 | KokoroSharp の vocab を確認できれば可能 | +| D. 設計ドキュメント (`docs/guides/misaki-compatible-chinese.md`) の仕様表に従う | 高 | プロジェクト内の単一真実源 (SSOT) として採用 | + +**採用**: **D 優先 + B で補完**。設計ドキュメントの仕様表を SSOT として採用し、Misaki の README や論文記載の例(`ni↓xau̯↓` 等)で妥当性確認。 + +### 5-2. Unicode 正規化の影響 + +**懸念**: Misaki出力の文字列比較で NFC/NFD 正規化の差異により `Assert.Equal` が失敗する可能性。特に以下: +- 非音節化符号 `U+032F`(結合記号、NFD/NFC で扱いが異なる可能性) +- そり舌母音 `U+027B + U+0329`(結合記号シーケンス) + +**対策**: +1. すべてのテスト期待値に Unicode エスケープ(`\uXXXX`)を使用し、ソースコードエディタの正規化を回避 +2. 長いテスト期待値は複数の `Assert.Contains` で検証し、完全一致 (`Assert.Equal`) は避ける方向で設計 +3. 実装側で `StringComparison.Ordinal` を明示的に使用することを T03 レビュー時にも確認 + +**レビュー項目**: PinyinToMisaki.cs の出力が NFC 正規化済みか確認。必要なら `string.Normalize(NormalizationForm.FormC)` を挟む。 + +### 5-3. テストデータの信頼性 + +**懸念**: 漢字→ピンイン→声調変調→Misaki IPA の各段階で使用する辞書やルールが正しく設定されていないと、期待値が実装と合っていても「実は誤った共通認識」で合格するリスク。 + +**対策**: +- 声調変調テストはまず `ToPinyin` / `ToIPA` の出力を確認し、既存テストでの正しい声調番号を把握してから期待値を設計 +- Issue #56 のテスト (7.1) は `Assert.Equal` 完全一致ではなく、複数の `Assert.Contains` で段階的に検証(矢印記号の存在、非音節化符号の存在、子音/母音の構成要素の存在) +- レビュー時に `ITestOutputHelper` で実際の出力をログ出力し、人間が目視確認できるようにする + +**レビュー項目**: +- 声調変調が含まれるテストは `enableToneSandhi` のデフォルト値を明示的に確認 +- 多音字(`好`, `行`, `一`, `不` 等)のテストはフレーズ辞書の影響を考慮 +- `ITestOutputHelper` を使ったデバッグ出力の追加を検討 + +### 5-4. T01-T03 の実装詳細に依存するテスト + +**懸念**: テスト実装時点で `PinyinToMisaki` / `ToMisakiIpa()` の正確なシグネチャが確定していないと、テストコードがコンパイルエラーになる。 + +**対策**: +- **T03 完了後に着手** (`depends_on: [T03]` として明示) +- T03 で `PinyinToMisaki.cs` と `ChineseG2PEngine.cs` が確定したことを確認してから実装開始 +- シグネチャの変更が発生した場合は T04 のテストも同期修正 + +### 5-5. 既存テストへの影響 + +**懸念**: 新しいテストファイル追加に伴い xUnit のコンストラクタ/Dispose でリソース競合が起きる可能性(低い)。 + +**対策**: `ChinesePiperIpaTests.cs` と同じ `IDisposable` パターンで `ChineseG2PEngine` を使い捨てする。並列実行による辞書ロード競合は既存テストでも起きていないため問題なし。 + +### 5-6. レビュー項目チェックリスト + +- [ ] すべてのテストメソッド名が日本語で、内容を明確に表現している +- [ ] `[Theory]` の `InlineData` で Unicode エスケープを統一している +- [ ] コンストラクタとDispose でリソース管理が正しく行われている +- [ ] 各セクションコメントが `// ===...===` 形式で統一されている +- [ ] `Assert.Equal` vs `Assert.Contains` の使い分けが妥当(完全一致はリスクが高いので Contains を優先) +- [ ] テストの独立性が保たれている(順序依存なし) +- [ ] 回帰テスト(セクション12)が存在している +- [ ] Issue #56 の再現テスト(セクション7)が存在している +- [ ] バッチAPIテスト(セクション8)が存在している +- [ ] Dispose後テスト(セクション9)が存在している +- [ ] xUnit の `ITestOutputHelper` を活用してデバッグログを残している(任意、推奨) + +--- + +## 6. 一から作り直すとしたら + +現状の「既存 `ChinesePiperIpaTests.cs` のパターンを踏襲する」アプローチは保守性・レビュー容易性が高く妥当。しかし、もし白紙から設計するなら以下のアプローチも検討できる。 + +### 6-1. パラメタライズドテスト中心の設計 + +現行案は `[Fact]` と `[Theory]` が混在しているが、初期設計から `[Theory]` + 大規模 `InlineData` に寄せることで: +- テスト本体のコード量を削減 +- 新ケース追加時のコスト削減 +- テストカバレッジの可視化が容易 + +**例**: +```csharp +[Theory] +[InlineData("妈", "ma\u2192", "声調1声")] +[InlineData("麻", "ma\u2197", "声調2声")] +[InlineData("马", "ma\u2193", "声調3声")] +[InlineData("骂", "ma\u2198", "声調4声")] +[InlineData("吗", "ma", "軽声")] +public void ToMisakiIpa_声調網羅(string hanzi, string expected, string description) +{ + var result = _engine.ToMisakiIpa(hanzi); + Assert.Equal(expected, result); +} +``` + +### 6-2. TSV データ駆動テスト + +Portuguese/Spanish パッケージで採用されている方式: +- `tests/DotNetG2P.Tests/ChineseG2P/data/misaki_expected.tsv` に `漢字\tピンイン\tMisakiIPA` 形式で 100 ケース以上 +- `MemberData` で TSV を読み込んで `[Theory]` に流し込む +- Misaki Python 実装の出力を元にした実データを蓄積できる + +**メリット**: +- 大量のリアルデータでカバレッジ向上 +- 実装者が手でコードを書かずにデータのみで拡張可能 +- 他言語パッケージとの設計統一 + +**デメリット**: +- 初期セットアップ工数が増える +- TSV の生成元(Misaki実装)に依存 + +**判断**: 本 T04 では採用見送り。Mi3 (`ChineseMisakiEdgeCaseTests.cs` または `ChineseMisakiDatasetTests.cs`) で検討する。 + +### 6-3. Misaki との差分レポート自動生成ツール + +`tools/DotNetG2P.MisakiEval/` を新規作成し、以下を自動化: +1. 頻出漢字 1000 語リストを `ToMisakiIpa()` で変換 +2. Misaki Python 実装と比較 +3. 差分率(Phone Error Rate 相当)をレポート + +**利点**: +- 品質を定量的に保証 +- Misaki の仕様変更時の影響を定量把握 +- PER 0% を目標値として追跡可能 + +**判断**: Mi3 で検討。本 T04 のスコープ外(テスト実装のみ)。 + +### 6-4. Snapshot Testing の導入 + +`Verify.Xunit` などのスナップショットテストライブラリを使用: +- 初回実行時に実際の出力を `.verified.txt` として保存 +- 2回目以降は差分チェック +- 意図的な変更時のみ `.received.txt` を承認 + +**利点**: +- テストコードが最小化 +- 差分の可視化 +- Issue #56 のような「期待値が動的」な状況に強い + +**デメリット**: +- 新しい依存パッケージの追加が必要 +- 既存パターンから逸脱 + +**判断**: 採用見送り。既存パターンとの整合性を優先。 + +### 6-5. カテゴリ分割の再設計 + +現状は単一ファイルだが、テスト件数が 100 件を超えた場合: +- `ChineseMisakiInitialsTests.cs` — 声母マッピング +- `ChineseMisakiFinalsTests.cs` — 韻母マッピング +- `ChineseMisakiTonesTests.cs` — 声調 +- `ChineseMisakiSandhiTests.cs` — 声調変調 +- `ChineseMisakiIpaTests.cs` — 統合・Issue#56再現 +- `ChineseMisakiEdgeCaseTests.cs` — エッジケース + +**判断**: Mi3 で検討。本 T04 では単一ファイル集約で開始し、保守性に問題が出たら分割する。 + +--- + +## 7. 後続タスクへの連絡事項 + +### 7-1. T05(ドキュメント更新)に伝えるべき情報 + +T04 完了時点で以下を T05 の作業者に引き継ぐこと: + +1. **テスト結果サマリ** + - 実装したテスト件数(合計、カテゴリ別) + - 全件パス/失敗した件数 + - 既存936件テストの回帰状況 + - `dotnet test DotNetG2P.slnx` の実行時間 + +2. **発見した差異・仕様確定事項** + - Misaki と DotNetG2P の実装差異で、設計ドキュメントに追記が必要な項目 + - 例: そり舌母音の区別 (`ʐ̩` vs `ɻ̩`) を初期実装で採用したか + - 例: Misaki の `ꭧ` (U+AB67) 対応を行ったか + - 声調変調の挙動で設計ドキュメント記載と異なる点があれば明記 + +3. **既知の制限事項** + - `嗯` などの非標準ピンインで Misaki 互換出力が空文字列になる + - 辞書に無い漢字のフォールバック挙動(パススルー) + - サロゲートペア・絵文字混在時の挙動 + +4. **パフォーマンス指標** + - `ToMisakiIpa` の実行時間が `ToIPA` / `ToPiperIPA` と同等であることの確認結果 + - Mi3 のパフォーマンステスト計画への引き継ぎ + +5. **Issue #56 再現テストの結果** + - `"你好"` の実際の出力(複数パターン: `ToMisakiIpa` / `ToMisakiIpa(text, includeTones:false)` / `enableToneSandhi:false`) + - Issue 本文の期待値 `ni↓xau̯↓` との一致状況 + - Issue へのコメント下書きを T05 のドキュメント更新タスクに含めること + +6. **README.md に記載すべき使用例** + ```csharp + using var engine = new ChineseG2PEngine(); + var result = engine.ToMisakiIpa("你好"); + // 出力例: "ni↗ xau̯↓" + ``` + +7. **CLAUDE.md 進捗テーブル更新** + - 中国語パッケージの備考に「Misaki互換出力対応(v1.10.0)」を追記 + - テスト件数を 936 から新しい合計値に更新 + +8. **Mi3 に持ち越す項目** + - パフォーマンステストの本実装 + - Misaki Python 実装との定量比較 + - 追加エッジケーステスト + - Multilingual 層への統合検討 + +### 7-2. T05 の前提条件 + +T05 の作業者は以下を確認してから作業開始すること: +- [x] T04 の全テストがパスしている +- [x] `dotnet test DotNetG2P.slnx` 全件パスしている +- [x] 本チケット (T04) の成果物が `main` または `feature/misaki-chinese` ブランチに反映されている +- [x] 設計ドキュメント `docs/guides/misaki-compatible-chinese.md` の「備考」セクションに T04 の発見事項が反映されている + +--- + +## 8. 紐づけ + +| 項目 | 値 | +|------|-----| +| **マイルストーン** | **Mi2** — ChineseG2PEngine API統合 + テスト | +| **依存** | **T03** — `PinyinToMisaki.cs` + `ChineseG2PEngine.ToMisakiIpa()` 実装完了 | +| **後続** | **T05** — ドキュメント更新(README.md, CLAUDE.md, 設計ドキュメント、Issue#56 コメント) | +| **関連 Issue** | [#56 — How can i make result similar like misaki does?](https://github.com/ayutaz/dot-net-g2p/issues/56) | +| **関連ドキュメント** | [docs/guides/misaki-compatible-chinese.md](../guides/misaki-compatible-chinese.md) | +| **関連ドキュメント** | [docs/guides/misaki-milestones.md](../guides/misaki-milestones.md) | +| **参考テスト** | `tests/DotNetG2P.Tests/ChineseG2P/ChinesePiperIpaTests.cs`(パターン参考) | +| **参考テスト** | `tests/DotNetG2P.Tests/ChineseG2P/ChinesePiperIpaComparisonTests.cs`(比較パターン) | +| **参考テスト** | `tests/DotNetG2P.Tests/ChineseG2P/ChineseG2PEngineC4Tests.cs`(IPA出力パターン) | +| **参考テスト** | `tests/DotNetG2P.Tests/ChineseG2P/ChinesePiperEdgeCaseTests.cs`(エッジケースパターン) | +| **参考テスト** | `tests/DotNetG2P.Tests/ChineseG2P/ChinesePiperIntegrationTests.cs`(統合テストパターン) | +| **作業ブランチ(想定)** | `feature/misaki-chinese-tests` | +| **PR タイトル(想定)** | `test: Misaki互換中国語G2P出力のテスト追加 (T04/Mi2)` | From 49f4304afbdbaa798532121d00c01f0b44a88935 Mon Sep 17 00:00:00 2001 From: yousan Date: Sun, 12 Apr 2026 22:04:29 +0900 Subject: [PATCH 06/21] =?UTF-8?q?docs:=20T05=20=E3=83=89=E3=82=AD=E3=83=A5?= =?UTF-8?q?=E3=83=A1=E3=83=B3=E3=83=88=E6=9B=B4=E6=96=B0=E3=83=BB=E5=93=81?= =?UTF-8?q?=E8=B3=AA=E4=BF=9D=E8=A8=BC=20=E3=83=81=E3=82=B1=E3=83=83?= =?UTF-8?q?=E3=83=88=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mi3 フェーズ1枚目。README.md(4箇所)、CLAUDE.md(進捗テーブル)、 設計ドキュメント、マイルストーン計画を更新するタスク。パフォーマンス テスト(ToMisakiIpa vs ToIPA の1.2倍以内)とMisaki Python実装との 出力比較テスト5種も含む。 関連: #56 --- docs/tickets/T05-documentation-qa.md | 290 +++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 docs/tickets/T05-documentation-qa.md diff --git a/docs/tickets/T05-documentation-qa.md b/docs/tickets/T05-documentation-qa.md new file mode 100644 index 0000000..89ba9bb --- /dev/null +++ b/docs/tickets/T05-documentation-qa.md @@ -0,0 +1,290 @@ +--- +ticket: T05 +title: ドキュメント更新・品質保証 +milestone: Mi3 +status: 未着手 +depends_on: [T04] +blocks: [T06] +--- + +# T05: ドキュメント更新・品質保証 + +## 1. タスク目的とゴール + +T01-T04 で実装・テスト完了した Misaki 互換中国語 G2P 出力(`ToMisakiIpa()` / `ToMisakiIpaBatch()`)について、ドキュメント整備と品質保証を行い、利用者が Misaki 互換出力を正しく使えるようにする。 + +**ゴール:** + +- 利用者が README.md を読むだけで `ToMisakiIpa()` の使い方と出力形式を理解できる +- Kokoro TTS (KokoroSharp) との連携に必要な情報がドキュメントに揃っている +- パフォーマンス面で `ToMisakiIpa` が `ToIPA` と同等の速度であることが確認されている +- CLAUDE.md の進捗テーブルが最新状態に更新されている +- 設計ドキュメント・マイルストーンドキュメントの完了状態が反映されている + +--- + +## 2. 実装する内容の詳細 + +### 2.1 README.md の更新 + +#### 2.1.1 冒頭コードサンプル(L22-24付近)への追記 + +現状の中国語サンプル: + +```csharp +// 中国語G2P(ピンイン変換) +using var zhEngine = new ChineseG2PEngine(); +zhEngine.ToPinyin("你好世界"); // => "ní hǎo shì jiè" +``` + +以下を直後に追加: + +```csharp +// 中国語G2P(Misaki互換IPA — Kokoro TTS向け) +zhEngine.ToMisakiIpa("你好世界"); // => "ni↗xau̯↓ʂʐ̩↘ʨiɛ↘" +``` + +#### 2.1.2 特徴セクション(L78付近)の中国語G2P説明への追記 + +現状: + +> **中国語G2P対応** — pinyin-data単字辞書(44,000語)+ phrase-pinyin-dataフレーズ辞書(411,000語)による多音字自動解決、声調変調(三声連読・一/不変調)、3種の出力スタイル、IPA(国際音声記号)・注音符号(ボポモフォ)出力、piper-plus 互換 IPA/PUA/Prosody API + +変更後: + +> **中国語G2P対応** — pinyin-data単字辞書(44,000語)+ phrase-pinyin-dataフレーズ辞書(411,000語)による多音字自動解決、声調変調(三声連読・一/不変調)、4種の出力スタイル、IPA(国際音声記号)・注音符号(ボポモフォ)出力、Misaki互換IPA出力(Kokoro TTS向け矢印声調・非音節化符号方式)、piper-plus 互換 IPA/PUA/Prosody API + +(「3種」→「4種」、「Misaki互換IPA出力(Kokoro TTS向け矢印声調・非音節化符号方式)」を追加) + +#### 2.1.3 詳細コードサンプル(L204-241付近、中国語G2Pセクション)への追記 + +`// Prosody 情報` の後に以下を追加: + +```csharp +// Misaki 互換 IPA(Kokoro TTS向け) +string misakiIpa = zhEngine.ToMisakiIpa("你好"); +// => "ni↗xau̯↓" +// 声調記号: → (1声), ↗ (2声), ↓ (3声), ↘ (4声) +// 声母: j→ʨ, q→ʨʰ, z→ʦ, c→ʦʰ +// 韻母: ai→ai̯, ao→au̯, ei→ei̯, ou→ou̯ (非音節化符号) + +// 声調なし Misaki 互換 IPA +string misakiNoTone = zhEngine.ToMisakiIpa("你好", includeTones: false); +// => "nixau̯" + +// バッチ変換 +string[] misakiBatch = zhEngine.ToMisakiIpaBatch(new[] { "你好", "世界" }); +``` + +#### 2.1.4 API リファレンステーブル(L481-512付近、ChineseG2PEngine セクション)への追記 + +既存テーブルの `ToIpaWithProsodyBatch(texts, includeTones)` 行の後に以下の行を追加: + +``` +| `ToMisakiIpa(text)` | `string` | Misaki互換IPA文字列(矢印声調記号付き) | +| `ToMisakiIpa(text, includeTones)` | `string` | 声調制御付きMisaki互換IPA | +| `ToMisakiIpaBatch(texts)` | `string[]` | バッチMisaki互換IPA変換 | +| `ToMisakiIpaBatch(texts, includeTones)` | `string[]` | バッチMisaki互換IPA変換(声調制御) | +``` + +### 2.2 CLAUDE.md の更新 + +#### 2.2.1 進捗状況テーブル(L18付近)の中国語行を更新 + +現状: + +``` +| 中国語 | DotNetG2P.Chinese | C1-C6完了 | 936 | pinyin-data 44k + phrase-pinyin-data 412kエントリ、声調変調、IPA/注音/piper-plus互換 | +``` + +更新後: + +``` +| 中国語 | DotNetG2P.Chinese | C1-C6完了 | 936+ | pinyin-data 44k + phrase-pinyin-data 412kエントリ、声調変調、IPA/注音/piper-plus互換、Misaki互換IPA出力(Kokoro TTS向け) | +``` + +(テスト数を「936+」に更新(Misakiテスト追加分)、備考に「Misaki互換IPA出力(Kokoro TTS向け)」を追加) + +#### 2.2.2 プロジェクト概要(L7-8付近) + +変更不要。「pinyin-data辞書ベースの中国語ピンイン変換」の記述は変換方式の説明であり、出力フォーマットの追加は特徴セクションでカバーされるため。 + +### 2.3 docs/guides/misaki-compatible-chinese.md の最終更新 + +- 「備考」セクションに Mi1-Mi3 完了後の知見を追記 +- 実装で判明した Misaki との差異や注意点があれば記録 +- 「実装方式」セクションの「採用: 方式B」が実際の実装と一致していることを確認 + +### 2.4 docs/guides/misaki-milestones.md の進捗更新 + +マイルストーン進捗サマリテーブル(L136-140)を更新: + +現状: + +``` +| **Mi1** | PinyinToMisaki 変換クラス | 未着手 | +| **Mi2** | API統合 + テスト | 未着手 | +| **Mi3** | ドキュメント・品質保証・リリース準備 | 未着手 | +``` + +更新後: + +``` +| **Mi1** | PinyinToMisaki 変換クラス | 完了 | +| **Mi2** | API統合 + テスト | 完了 | +| **Mi3** | ドキュメント・品質保証・リリース準備 | 完了 | +``` + +--- + +## 3. 実装するために必要なエージェントチームの役割と人数 + +| 役割 | 人数 | 担当内容 | +|------|------|---------| +| ドキュメントライター | 1名 | README.md、CLAUDE.md、設計ドキュメントの更新。コードサンプルの正確性確認 | +| QAエンジニア | 1名 | パフォーマンステスト作成・実行、Misaki Python実装との出力比較、コードサンプルの動作確認 | + +**合計: 2名** + +ドキュメントライターとQAエンジニアは並行作業が可能。ドキュメントライターがコードサンプルを書き、QAエンジニアがそのサンプルの動作確認を行う。 + +--- + +## 4. 提供範囲とテスト項目 + +### 4.1 ドキュメント正確性チェック + +- [ ] README.md のコードサンプルが実際にコンパイル・実行できること + - `ToMisakiIpa("你好")` のコメントに書かれた出力例が実際の出力と一致すること + - `ToMisakiIpa("你好世界")` のコメントに書かれた出力例が実際の出力と一致すること + - バッチ API のサンプルが正しいこと +- [ ] API リファレンステーブルのメソッドシグネチャが実装と一致すること + - 戻り値型(`string` / `string[]`)が正しいこと + - オーバーロードの引数名・型が正しいこと +- [ ] CLAUDE.md のテスト数が `dotnet test` の実行結果と一致すること +- [ ] misaki-milestones.md の各マイルストーンのチェックリストが全て完了状態であること + +### 4.2 コードサンプル動作確認 + +- [ ] README.md に記載した全コードスニペットを DotNetG2P.Console サンプルプロジェクト等で実行し、コメントの出力例と一致することを確認 +- [ ] 声調記号(→ ↗ ↓ ↘)が正しく表示されること(Unicode の矢印文字が化けないこと) + +### 4.3 パフォーマンステスト + +- [ ] `ToMisakiIpa` が `ToIPA` と同等の処理速度であることを確認 + - 測定方法: 同一テキストセット(100文以上)を各メソッドで1000回変換し、平均処理時間を比較 + - 許容範囲: `ToMisakiIpa` の処理時間が `ToIPA` の 1.2 倍以内 + - 根拠: `PinyinToMisaki` は `PinyinToIpa` と同じく静的辞書参照のみでアルゴリズム計算量は同等。声調マッピングが配列インデックスアクセスのため差が出にくい +- [ ] テストクラス `ChineseMisakiPerformanceTests.cs` の作成(任意) + - BenchmarkDotNet または Stopwatch による簡易ベンチマーク + - CI に組み込む場合は `[Trait("Category", "Performance")]` で分離 + +### 4.4 Misaki Python実装との出力比較(可能な範囲) + +- [ ] Misaki Python パッケージ (`pip install misaki`) をローカル環境にインストールし、以下のテストケースで出力を比較: + - 基本: `"你好"` → 期待: `ni↗xau̯↓`(三声連読: 3+3 → 2+3) + - 四声: `"妈麻马骂"` → 各声調が正しい矢印で出力されること + - 声母差異: `"鸡七"` → `j/q` が `ʨ/ʨʰ` で出力されること + - 韻母差異: `"来回"` → `ai/ui` が非音節化符号付きで出力されること + - 声調変調: `"一个"` → 「一」の変調が反映されること +- [ ] 差異がある場合は misaki-compatible-chinese.md の備考セクションに記録 + +--- + +## 5. 実装に関する懸念事項とレビュー項目 + +### 5.1 ドキュメントの言語対応 + +- **現状**: プロジェクトは日本語で記述(CLAUDE.md「開発言語」セクションに明記)。README.md は日本語版が主、英語版 (`README_EN.md`) と中国語版 (`README_ZH.md`) が別途存在 +- **懸念**: Misaki互換機能の主要ユーザーは Kokoro TTS / KokoroSharp のユーザーであり、英語圏・中国語圏のユーザーも多い +- **対応方針**: + - 本チケットでは日本語 README.md のみを更新対象とする + - README_EN.md / README_ZH.md への反映は T06(Issue#56 フォローアップ)で対応する + - コードサンプルはプログラミング言語(C#)で書かれるため、日本語コメント以外の部分は言語非依存 + +### 5.2 Misaki 仕様変更への追従方針 + +- **懸念**: Misaki には Legacy パス(IPA+矢印)と v1.1 パス(注音符号)の2つが存在する。現在の実装は Legacy パスのみ対象。将来 Misaki が仕様変更した場合にドキュメントが陳腐化するリスクがある +- **対応方針**: + - ドキュメントに「本機能は Misaki Legacy パス(Kokoro-82M で使用される IPA+矢印方式)を対象とする」と明記する + - Misaki のバージョンや対象コミットハッシュをドキュメントに記録しておく + - 仕様変更時は `PinyinToMisaki.cs` のマッピングテーブル差分のみの修正で対応可能な設計であることをドキュメントに記載する + +### 5.3 レビュー項目 + +- [ ] README.md のコードサンプル内のコメント出力例が、実際の `ToMisakiIpa()` 出力と完全一致すること +- [ ] Unicode 文字(矢印声調記号 → ↗ ↓ ↘、非音節化符号 U+032F)がドキュメント内で正しくレンダリングされること(GitHub Markdown ビューアで確認) +- [ ] CLAUDE.md の更新が他のセクション(プロジェクト概要、技術スタック等)と整合していること +- [ ] API リファレンステーブルの追加行が既存行のフォーマット(パイプ区切り、等幅フォント等)と一致していること +- [ ] misaki-milestones.md のチェックボックスが全て `[x]` になっていること + +--- + +## 6. 一から作り直すとしたら + +### 6.1 ドキュメント生成の自動化 + +現状の README.md は手動メンテナンスで約600行以上に達しており、APIリファレンステーブルの更新が手作業である。一から設計し直すなら: + +- **docfx によるAPI ドキュメント自動生成**: XML ドキュメントコメントから API リファレンスを自動生成し、README.md からは API テーブルを除去。README にはクイックスタートと概要のみを記載し、詳細は docfx サイトへリンクする +- **テスト結果からの自動ドキュメント生成**: `[Fact]` テストの `DisplayName` や `InlineData` からサンプル入出力ペアを抽出し、ドキュメントのコードサンプルを自動更新するスクリプトを用意する。これにより「ドキュメントの出力例と実際の出力が異なる」問題を根本解決できる +- **Verify ライブラリの活用**: テストの期待値をスナップショットファイルで管理し、ドキュメントと共有する仕組み + +### 6.2 CHANGELOG 管理方式の再考 + +現状はリリースタグとGitHub Releasesで変更履歴を管理しているが: + +- **conventional commits + 自動 CHANGELOG 生成**: コミットメッセージ規約(`feat:`, `fix:`, `docs:` 等)を導入し、`release-please` や `standard-version` で CHANGELOG.md を自動生成する。Misaki 互換出力のような機能追加が自動的に CHANGELOG に反映される +- **Keep a Changelog 形式の手動 CHANGELOG.md**: 各リリースごとに Added / Changed / Fixed を手動記載。自動化ほど効率的でないが、日本語プロジェクトとの相性は良い + +### 6.3 言語別 README の統合管理 + +現状の README.md / README_EN.md / README_ZH.md の3ファイル手動管理は更新漏れが起きやすい。テンプレートエンジン(Scriban等)で共通テンプレートから多言語READMEを生成する方式が理想的。 + +--- + +## 7. 後続タスクへの連絡事項 + +T06(Issue #56 フォローアップ)に伝えるべき情報: + +### 7.1 Issue #56 へのフォローアップコメント + +- T05 完了後、Issue #56 に実装完了の報告コメントを投稿すること +- コメントには以下を含める: + - `ToMisakiIpa()` の使用例コード + - NuGet パッケージバージョン(Mi3 リリース後のバージョン番号) + - 既知の制限事項(Legacy パスのみ対応、Multilingual 層未統合等) + +### 7.2 README_EN.md / README_ZH.md への反映 + +- T05 では日本語 README.md のみを更新する +- T06 で英語版・中国語版への反映を行うこと +- 中国語版は Misaki の主要ユーザー層と重なるため、特に丁寧な記述が望ましい + +### 7.3 Multilingual 層への統合検討 + +- T01-T04 の実装では `DotNetG2P.Multilingual` への統合は見送っている +- `MultilingualG2PEngine` に `ToMisakiIpa()` を追加するかは T06 で検討すること +- 追加する場合、`MultilingualG2POptions` に Misaki 出力モードの設定が必要になる可能性がある + +### 7.4 パフォーマンステスト結果の引き継ぎ + +- T05 で実施したパフォーマンステスト結果(ToMisakiIpa vs ToIPA の処理時間比較)を T06 に引き継ぐ +- 性能劣化が見られた場合はその原因と改善案を記録しておくこと + +### 7.5 Misaki 仕様追従の監視 + +- Misaki リポジトリ (https://github.com/hexgrad/misaki) の更新を定期的に確認する体制を T06 で検討すること +- 特に v1.1 パス(注音符号方式)への対応要否は KokoroSharp コミュニティの需要次第 + +--- + +## 8. 紐づけ + +- **マイルストーン**: Mi3(ドキュメント・品質保証・リリース準備) +- **依存**: T04(Mi2: API統合 + テスト — 全実装・テストが完了していること) +- **後続**: T06(Issue #56 フォローアップ — 英語/中国語版README反映、Issue コメント投稿、Multilingual統合検討) +- **関連Issue**: [#56 - How can i make result similar like misaki does?](https://github.com/ayutaz/dot-net-g2p/issues/56) +- **関連ドキュメント**: + - [docs/guides/misaki-compatible-chinese.md](../guides/misaki-compatible-chinese.md) — 設計ドキュメント + - [docs/guides/misaki-milestones.md](../guides/misaki-milestones.md) — マイルストーン計画 From 911107a36f7af9fd7634089d3db8da1d89639704 Mon Sep 17 00:00:00 2001 From: yousan Date: Sun, 12 Apr 2026 22:04:38 +0900 Subject: [PATCH 07/21] =?UTF-8?q?docs:=20T06=20Issue#56=20=E3=83=95?= =?UTF-8?q?=E3=82=A9=E3=83=AD=E3=83=BC=E3=82=A2=E3=83=83=E3=83=97=E3=83=BB?= =?UTF-8?q?=E3=83=AA=E3=83=AA=E3=83=BC=E3=82=B9=E6=BA=96=E5=82=99=20?= =?UTF-8?q?=E3=83=81=E3=82=B1=E3=83=83=E3=83=88=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mi3 フェーズ2枚目(最終)。v1.10.0(MINOR bump、新API追加)として計11 ファイルのバージョン更新、CI 6マトリクス確認、NuGet/UPM同時リリース、 Issue #56フォローアップコメント投稿を行うタスク。リリース後の将来課題 (Multilingual統合、Misaki v1.1注音パス、他言語Kokoro互換)も記載。 関連: #56 --- docs/tickets/T06-release-followup.md | 562 +++++++++++++++++++++++++++ 1 file changed, 562 insertions(+) create mode 100644 docs/tickets/T06-release-followup.md diff --git a/docs/tickets/T06-release-followup.md b/docs/tickets/T06-release-followup.md new file mode 100644 index 0000000..825f881 --- /dev/null +++ b/docs/tickets/T06-release-followup.md @@ -0,0 +1,562 @@ +--- +ticket: T06 +title: Issue#56 フォローアップ・リリース準備 +milestone: Mi3 +status: 未着手 +depends_on: [T05] +blocks: [] +--- + +# T06: Issue#56 フォローアップ・リリース準備 + +## 1. タスク目的とゴール + +Mi3 マイルストーン(Misaki 互換中国語出力)の最終チケット。T01〜T05 で Misaki 互換 +API の実装・テスト・ドキュメントが完了済みの状態から、Issue #56 質問者への回答を確定し、 +新機能を NuGet/UPM の両チャネルに同時リリースする。 + +### ゴール + +- Issue #56 に実装完了の報告コメントを投稿し、使用例と Kokoro 連携での動作確認手順を提示する +- Misaki 互換機能を含む新バージョンを `DotNetG2P.Chinese`/`DotNetG2P.Multilingual` を中心に + リリースし、NuGet と Unity Package Manager の両方で取得可能な状態にする +- ホームの README/CLAUDE.md を新バージョンに揃え、CI/リリースワークフローが緑であることを確認する +- Issue #56 を「完了」として正式にクローズする(質問者の動作確認後) + +### 非ゴール + +- 新しい機能追加(本チケットの範囲は「確定済み実装のリリース」のみ) +- 他言語(日本語/韓国語等)の Kokoro/Misaki 互換対応(将来課題) +- Kokoro 本体との統合テストやサンプルプロジェクト新規作成(動作確認手順の提示のみ) + +--- + +## 2. 実装する内容の詳細 + +### 2.1 バージョン番号の決定 + +現行バージョン: **v1.9.0**(`Directory.Build.props` の `` 及び各 `package.json`) + +**推奨: v1.10.0(マイナーバージョン)** + +根拠: +- セマンティックバージョニング (SemVer 2.0.0) に従うと、 + - **MAJOR**: 後方互換性のない API 変更 + - **MINOR**: 後方互換を維持した機能追加 + - **PATCH**: 後方互換を維持したバグ修正 +- Mi3 は「Misaki 互換の新しい出力 API(`ToMisaki()` 等)の追加」であり、 + 既存の `ToPinyin()` / `ToIPA()` 等の挙動を変更しない → **機能追加** +- よって **MINOR** バージョン(1.9.0 → 1.10.0)が適切 +- パッチ (1.9.1) は「バグ修正のみ」の意味になるため不適切 +- メジャー (2.0.0) は破壊的変更がないため過剰 + +破壊的変更の有無チェック項目: +- `ChineseG2PEngine` の既存公開メソッドのシグネチャ変更なし +- `ChineseG2POptions` の既存プロパティ削除/型変更なし +- enum (`PinyinStyle`, `ChineseIpaPhoneme` 等) の既存値の序数変更なし +- `DotNetG2P.Multilingual` の `Language` enum の既存値の序数変更なし +- 既存公開クラスの `sealed`/`abstract`/`static` 修飾子追加なし +- → すべて満たせば v1.10.0 で確定 + +### 2.2 バージョン更新対象ファイル + +以下のファイルをすべて `1.9.0` → `1.10.0` に更新する: + +| ファイル | 箇所 | +|---------|------| +| `Directory.Build.props` | `1.10.0` を 2 行 | +| `src/DotNetG2P.Core/package.json` | `"version": "1.10.0"` | +| `src/DotNetG2P.MeCab/package.json` | `"version": "1.10.0"` | +| `src/DotNetG2P.Chinese/package.json` | `"version": "1.10.0"` | +| `src/DotNetG2P.English/package.json` | `"version": "1.10.0"` | +| `src/DotNetG2P.Korean/package.json` | `"version": "1.10.0"` | +| `src/DotNetG2P.Spanish/package.json` | `"version": "1.10.0"` | +| `src/DotNetG2P.French/package.json` | `"version": "1.10.0"` | +| `src/DotNetG2P.Portuguese/package.json` | `"version": "1.10.0"` | +| `src/DotNetG2P.Swedish/package.json` | `"version": "1.10.0"` | +| `src/DotNetG2P.Multilingual/package.json` | `"version": "1.10.0"` | +| `CLAUDE.md` | 「現在 v1.10.0」に更新、進捗表に Mi3 を追加 | +| `README.md` | バージョンバッジ、使用例のインストールコマンド | + +全パッケージ同時バージョンアップとする理由: モノレポ方針により、個別昇格は運用コストが高く、 +`Directory.Build.props` の共通 `` で一括管理している現状を維持する。 + +### 2.3 Issue #56 へのコメント文面案 + +````markdown +@<質問者> お待たせいたしました。Misaki 互換中国語出力の対応が完了しましたのでご報告します。 + +## 実装内容 + +v1.10.0 で `DotNetG2P.Chinese` に Misaki 互換出力 API を追加しました。 +Kokoro の Misaki フロントエンドと同じ音素列/トークナイズ形式でピンインを取得できます。 + +- `ChineseG2PEngine.ToMisaki(string text)` — Misaki 互換音素列を文字列で返す +- `ChineseG2PEngine.ToMisakiTokens(string text)` — トークンごとの情報(表層形・声調番号・音素列)を配列で返す +- `ChineseG2POptions.MisakiCompatibility` — Misaki の挙動に合わせた補正フラグ + +`DotNetG2P.Multilingual` 側からも `MultilingualG2PEngine.ToMisaki(text, Language.Chinese)` で +呼び出せます(言語自動判定にも対応)。 + +## 使用例(NuGet) + +```csharp +using DotNetG2P.Chinese; + +using var engine = new ChineseG2PEngine(); + +// Misaki 互換音素列 +string misaki = engine.ToMisaki("你好世界"); +// => "ni3 hao3 shi4 jie4"(例) + +// トークン単位情報 +var tokens = engine.ToMisakiTokens("你好世界"); +foreach (var t in tokens) +{ + Console.WriteLine($"{t.Surface} / {t.Pinyin} / tone={t.Tone}"); +} +``` + +インストール: + +```bash +dotnet add package DotNetG2P.Chinese --version 1.10.0 +# または多言語ファサード +dotnet add package DotNetG2P.Multilingual --version 1.10.0 +``` + +Unity (UPM) の場合は `Packages/manifest.json` に以下を追加: + +```json +{ + "dependencies": { + "com.dotnetg2p.chinese": "https://github.com/ayutaz/dot-net-g2p.git?path=src/DotNetG2P.Chinese#v1.10.0" + } +} +``` + +## Kokoro 連携での動作確認手順 + +1. 本ライブラリ v1.10.0 をインストール +2. `ToMisaki()` で得た音素列を Kokoro の入力へそのまま渡す +3. Misaki 本体(Python)と同一入力で出力差分を比較(T04 で提供しているゴールデンデータセットを利用可能) + +差分が発生するケースや想定外の挙動がありましたら、本 Issue に再コメントをお願いします。 +動作確認が取れましたら Issue をクローズいたします。改めてご報告ありがとうございました。 +```` + +注: `ToMisaki` / `ToMisakiTokens` / `MisakiCompatibility` の正確な名前・シグネチャは T01〜T05 の +実装に合わせて確定する(上記は想定例)。 + +### 2.4 リリースノート案(GitHub Release) + +```markdown +## v1.10.0 — Misaki 互換中国語出力 + +### 新機能 +- **DotNetG2P.Chinese**: Misaki(Kokoro フロントエンド)互換の音素列出力 API を追加 + - `ChineseG2PEngine.ToMisaki(text)` — Misaki 互換音素列を文字列で返す + - `ChineseG2PEngine.ToMisakiTokens(text)` — トークン単位の情報配列を返す + - `ChineseG2POptions.MisakiCompatibility` — Misaki 準拠モードフラグ +- **DotNetG2P.Multilingual**: `MultilingualG2PEngine.ToMisaki()` ファサード追加 +- Misaki 相当のトークナイズ/声調処理/句読点ポリシーを実装 + +### 改善 +- Chinese ピンイン辞書ルックアップの微最適化(T02 の副産物) +- ドキュメント `docs/chinese-misaki.md` 追加(使用法・差分仕様) + +### 後方互換性 +- 破壊的変更なし(既存 API のシグネチャ・挙動は v1.9.x と同一) +- 既存の `ToPinyin()` / `ToIPA()` 等は従来どおり動作 + +### 関連 Issue +- Fixes #56: Misaki 互換中国語出力の要望 + +### NuGet パッケージ +全 10 パッケージを v1.10.0 として同時リリース: +`DotNetG2P`, `DotNetG2P.MeCab`, `DotNetG2P.Chinese`, `DotNetG2P.English`, +`DotNetG2P.Korean`, `DotNetG2P.Spanish`, `DotNetG2P.French`, `DotNetG2P.Portuguese`, +`DotNetG2P.Swedish`, `DotNetG2P.Multilingual` + +### SBOM +CycloneDX SBOM はリリース成果物 (`bom.xml`) に同梱。 +``` + +備考: `release.yml` の `gh release create ... --generate-notes` が自動生成するコミットログと +併用する前提。上の手書き部分はリリース概要セクションとして追記する(リリース作成後に手動編集、 +もしくは `--notes` オプションで差し替え)。 + +### 2.5 PR 作成内容 + +**ブランチ名案**: `release/v1.10.0-misaki` または `chore/v1.10.0-release` + +**PR タイトル(日本語)**: +「v1.10.0 リリース準備 — Misaki 互換中国語出力対応 (#56)」 + +**PR 本文**: +```markdown +## 概要 + +Mi3 マイルストーン(Issue #56)の最終作業として、v1.9.0 → v1.10.0 のリリース準備を行う。 + +## 変更内容 + +- `Directory.Build.props` のバージョンを 1.10.0 に更新 +- 全 UPM パッケージの `package.json` を 1.10.0 に更新 +- `CLAUDE.md` の進捗表に「中国語(Misaki互換) Mi1-Mi3 完了」を追記 +- `README.md` のバージョン表記を更新 +- (必要に応じて)`docs/chinese-misaki.md` のクロスリンク調整 + +## リリース後の作業(本 PR マージ後) + +1. `workflow_dispatch` で `release.yml` を実行(input: `version=1.10.0`) +2. Issue #56 に報告コメント投稿(本チケット 2.3 節参照) +3. 質問者の動作確認完了後に Issue #56 をクローズ + +## 依存 + +- T01〜T05(Misaki 互換機能の実装・テスト・ドキュメント)が main にマージ済みであること + +## チェック項目 + +- [ ] CI(ci.yml)全マトリクスグリーン +- [ ] `DotNetG2P.Tests` 全テスト通過(中国語 936 件以上 + Misaki 関連追加分) +- [ ] `Multilingual` テスト通過 +- [ ] Unity .meta 整合性チェック通過 +- [ ] `sync-shared-internals.ps1 -Check` 通過 +- [ ] Publish smoke (trim + AOT) 通過 +- [ ] DocFX ビルド警告ゼロ +- [ ] 破壊的変更なし(T06 2.1 節チェックリスト参照) +``` + +### 2.6 リリース実行手順 + +1. 本 PR を main にマージ +2. GitHub Actions の `Release` ワークフローを `workflow_dispatch` で起動 + - input `version`: `1.10.0`(先頭に `v` を付けない) +3. `release.yml` が以下を実行: + - `validate` ジョブ: バージョン形式検証、`v1.10.0` タグ未存在確認 + - `build-and-test` ジョブ: ビルド、DocFX、辞書セットアップ、テスト、 + 全 10 パッケージの `dotnet pack`、CycloneDX SBOM 生成、アーティファクト + アップロード (`nuget-packages`) + - `publish` ジョブ: NuGet.org への `dotnet nuget push`(`--skip-duplicate`)、 + GitHub Release の作成(`gh release create v1.10.0 ... --generate-notes`) +4. NuGet.org で 10 パッケージがインデックス化されたことを確認 +5. GitHub Release のノート本文を 2.4 節の文面で手動編集(ハイライト追加) +6. UPM 側は Git URL + tag `v1.10.0` で即座に参照可能になることを確認 +7. Issue #56 に 2.3 節のコメントを投稿 + +--- + +## 3. 実装するために必要なエージェントチームの役割と人数 + +本チケットは実装よりも調整・確認作業が主体のため少人数で完結する。 + +| 役割 | 人数 | 主な責務 | +|------|-----|---------| +| リリースマネージャ | 1 | バージョン確定、`Directory.Build.props` と全 `package.json` の一括更新、PR 作成、リリースワークフロー起動、NuGet/GitHub Release の最終確認 | +| テクニカルライター兼サポート | 1 | Issue #56 への回答コメント作成・投稿、リリースノート本文編集、README/CLAUDE.md の文言調整、質問者フォロー対応 | +| QA / リリース検証 | 1 | CI グリーン確認、NuGet から実際に `dotnet add package` して Misaki API が呼べること、UPM Git URL 経由で Unity 2021.2 に取り込めること、破壊的変更チェックリスト消化 | + +合計 **3 名**(総作業時間目安: 0.5〜1 人日)。兼務可能。 + +--- + +## 4. 提供範囲とテスト項目 + +### 4.1 提供範囲 + +- `Directory.Build.props` / 各 `package.json` のバージョン更新(11 ファイル) +- `CLAUDE.md` 進捗表更新 +- `README.md` バージョン表記更新 +- PR 作成・マージ +- `Release` ワークフロー実行による成果物発行: + - NuGet 10 パッケージ (`*.nupkg` + `*.snupkg`) + - GitHub Release `v1.10.0`(SBOM 同梱) + - Git tag `v1.10.0` +- Issue #56 報告コメント投稿 + +### 4.2 テスト項目 + +#### CI 全テスト通過確認(ci.yml) + +- `unity-meta-check` ジョブ + - 全 UPM パッケージの `.meta` ファイル整合性 + - `tools/sync-shared-internals.ps1 -Check` 通過 +- `build-test-and-validate` ジョブ(6 マトリクス) + - ubuntu-latest × .NET 8 + - ubuntu-latest × .NET 9(coverage + validate_pack) + - windows-latest × .NET 8 / 9 + - macos-latest × .NET 8 / 9 + - 各マトリクスで `dotnet test --filter "Category!=Performance"` グリーン +- `validate_pack` マトリクスで: + - DocFX ビルド警告ゼロ (`--warningsAsErrors`) + - Publish smoke trim (`DotNetG2PPublishTrimmedSmoke=true`) 成功 + - Publish smoke AOT (`DotNetG2PPublishAotSmoke=true`) 成功 + - 全 10 パッケージの `dotnet pack -p:EnablePackageValidation=true` 成功 + - CycloneDX SBOM 生成成功 + +#### テスト件数の目安 + +| パッケージ | 既存テスト数 | Mi3 追加見込み | 備考 | +|-----------|-----|-----|------| +| 日本語 | 950+ | 0 | 影響なし | +| 英語 | 511 | 0 | 影響なし | +| 中国語 | 936 | +N | Misaki 互換テスト追加分 | +| 韓国語 | 375 | 0 | 影響なし | +| スペイン語 | 355 | 0 | 影響なし | +| フランス語 | 719 | 0 | 影響なし | +| ポルトガル語 | 1310 | 0 | 影響なし | +| スウェーデン語 | 400+ | 0 | 影響なし | +| 多言語 | 450+ | +M | Multilingual ファサード経由の Misaki 呼び出しテスト | + +中国語・多言語以外のテスト件数が減っていたら退行扱い → 破壊的変更の疑いで調査。 + +#### リリースワークフロー確認(release.yml) + +- `validate` ジョブ: バージョン `1.10.0` が正規表現 `^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$` を通過 +- `v1.10.0` タグが未存在であることを確認 +- `build-and-test` ジョブ: ビルド/テスト/pack/SBOM 成功 +- `publish` ジョブ: + - 10 個の `.nupkg` + 10 個の `.snupkg` が `artifacts` にアップロード済み + - `dotnet nuget push --skip-duplicate` が全パッケージ成功 + - `gh release create v1.10.0` が成功し、リリース本文が自動生成される + +#### リリース後スモークテスト + +- クリーン環境(ローカル/Docker)で: + ```bash + dotnet new console -o TestMisaki + cd TestMisaki + dotnet add package DotNetG2P.Chinese --version 1.10.0 + # Program.cs に ChineseG2PEngine.ToMisaki("你好") を追加して実行 + dotnet run + ``` +- Unity 2021.2 以降の空プロジェクトで: + - `Packages/manifest.json` に Git URL + `#v1.10.0` を追加 + - コンパイルエラーなく `ChineseG2PEngine` が解決される + - IL2CPP ビルドで Misaki API がストリップされない(`[Preserve]` 効果確認) + +--- + +## 5. 実装に関する懸念事項とレビュー項目 + +### 5.1 セマンティックバージョニング + +- **新 API 追加はマイナーバージョン**が原則 (SemVer §7) +- `ToMisaki()` 等の追加は純粋な機能追加 → v1.9.0 → **v1.10.0** +- もし T01〜T05 で以下のような変更が入っていたら再検討が必要: + - 既存公開メソッドの戻り値型変更 → **v2.0.0** + - 既存 enum 値の削除・序数シフト → **v2.0.0** + - `ChineseG2POptions` の必須プロパティ追加 → **v2.0.0** +- **レビュー項目**: PR 作成前に `git diff v1.9.0..HEAD -- src/DotNetG2P.Chinese/*.cs src/DotNetG2P.Multilingual/*.cs` で公開 API を目視確認 + +### 5.2 NuGet/UPM の同時リリース + +- NuGet: `release.yml` の `publish` ジョブで `dotnet nuget push` により自動公開 +- UPM: Git tag `v1.10.0` を打った時点で即時公開(`package.json` のバージョンは Git tag とは独立だが、UPM は package.json の値を信頼する) +- **同時性の保証**: + - `release.yml` は tag 作成 → NuGet push の順(`gh release create "v$VERSION"` で tag 作成) + - NuGet インデックス化は 10〜30 分遅延することがあるため、Issue コメント投稿は NuGet.org で実際にパッケージが検索可能になったのを確認してから行う +- **懸念**: `package.json` のバージョンだけ更新し忘れると、UPM 側では旧バージョンとして扱われる + - → 2.2 節のチェックリストで 11 ファイル全更新を担保 + - → CI の `unity-meta-check` ジョブでは package.json バージョンの整合性は検査していない + ため、PR レビュー時に grep で全 `"version"` 値を目視確認 +- **推奨自動化(将来課題)**: `tools/bump-version.ps1`(仮)のようなスクリプトで 11 ファイル一括更新 + +### 5.3 破壊的変更の有無確認 + +- 2.1 節のチェックリストを PR レビューの必須項目とする +- 具体的な確認コマンド: + ```bash + git diff v1.9.0..HEAD -- 'src/DotNetG2P.Chinese/**/*.cs' | grep -E '^-\s+public ' + git diff v1.9.0..HEAD -- 'src/DotNetG2P.Multilingual/**/*.cs' | grep -E '^-\s+public ' + ``` + 削除された `public` 行がなければ API 削除なしと判断 +- `dotnet pack -p:EnablePackageValidation=true` により、前バージョンとの + API 差分で破壊的変更があれば自動検出される(CI で既に実行中)→ これがグリーンなら安全 + +### 5.4 辞書ファイルサイズの変化 + +- `pinyin_char.txt` / `pinyin_phrase.txt` が Mi2 で更新されている場合、 + NuGet パッケージサイズが増加する +- パッケージサイズ上限(NuGet.org: 250 MB)に近づいていないか確認 +- Unity の embedded resource 読み込み時間にも影響するため、 + Mi2 で辞書差分を最小化しているかレビュー + +### 5.5 `docs/chinese-misaki.md` / DocFX + +- DocFX ビルドは `--warningsAsErrors` で走るため、T05 で追加されたドキュメントに + デッドリンクや XML コメント不備があると CI 落ちする +- PR 前にローカルで `dotnet tool run docfx docs/docfx.json --warningsAsErrors` を実行 + +### 5.6 リリース後の Rollback 戦略 + +- NuGet.org は一度公開したパッケージを削除できない(unlist のみ可能) +- 重大な不具合が発覚した場合: + 1. 該当パッケージを NuGet.org UI で unlist + 2. 緊急パッチ v1.10.1 を即時リリース + 3. GitHub Release をドラフトに戻す(タグは残す) +- → リリース前のスモークテスト(§4.2 最終節)を必須化することで事前検知 + +### 5.7 CI 環境変数とシークレット + +- `release.yml` が依存するシークレット: + - `NUGET_API_KEY`(NuGet.org 発行のキー、90 日有効期限) + - `GITHUB_TOKEN`(自動発行) +- リリース前に Organization/Repo の Settings > Secrets で `NUGET_API_KEY` の有効期限を確認 + +--- + +## 6. 一から作り直すとしたら + +Mi1〜Mi3 全体、さらにプロジェクト全体のリリース管理方式を振り返る。 + +### 6.1 Mi1〜Mi3 全体の振り返り(Misaki 互換対応) + +**良かった点:** +- チケット駆動(T01〜T06 の 6 分割)により並行作業がしやすかった +- 既存の Chinese G2P パイプラインの拡張として実装でき、破壊的変更を避けられた + +**改善したい点:** +- Misaki の仕様書(Python 実装)との差分検証を T04 まで後ろ倒ししたため、T01 実装後に + 再設計が発生した可能性 → **スパイク(T0)を先に置くべき** +- Kokoro との連携テストを実機で回さなかったため、Issue 質問者の環境依存問題を + 本チケット 2.3 節の「動作確認手順」として提示するだけに留まっている → **Kokoro サンプル + プロジェクトをリポジトリ内に同梱する選択肢もあった** +- 中国語単体チケットだったが、他言語でも Misaki 互換の需要はあるはず → **初期設計で + `IMisakiCompatible` インタフェースを導入**しておけば、英語/日本語 Kokoro 対応の土台になった + +### 6.2 プロジェクト全体のリリース管理方式の再考 + +現状の課題: +- 全 10 パッケージを常に同一バージョンでリリース(モノレポ一括昇格)→ 変更がない + パッケージも毎回新バージョンが発行され、ユーザが「何が変わったのか」を追いにくい +- `Directory.Build.props` と各 `package.json` のバージョンが二重管理(11 箇所手動更新) +- NuGet リリースと Issue クローズの間に人手作業が多い(リリースノート編集、コメント投稿) + +**代替案 A: Feature flag 方式** + +- `ChineseG2POptions.MisakiCompatibility = false` をデフォルトとして v1.10.0 に同梱し、 + デフォルト挙動は v1.9.x と完全互換にする +- ユーザが明示的に opt-in した時のみ Misaki 互換動作 +- メリット: 万一のバグでも既存ユーザに影響なし、安全にメインにマージできる +- デメリット: 1 年後にデフォルト切替のための v2.0 が必要 +- 本プロジェクトの T01〜T05 実装がすでに「新 API 追加」方式(既存 API 不変)であれば、 + flag 方式と実質同等の互換性は得られている + +**代替案 B: プレビュー版リリース(`-preview.N`)** + +- `v1.10.0-preview.1` を先に NuGet に公開(`release.yml` のバージョン検証正規表現は + 既に `-[a-zA-Z0-9.]+` サフィックスに対応済み) +- Issue #56 質問者に先行提供してフィードバックを得る +- 問題なければ `v1.10.0` を正式リリース +- 本プロジェクトのように外部ユーザからの要望ベースで機能追加する場合、 + **プレビュー版 → 安定版の 2 段階リリース**が最も安全 +- T06 を以下のように再構成する案: + - T06a: v1.10.0-preview.1 リリース + Issue #56 に preview 案内 + - T06b: 質問者フィードバック反映 + v1.10.0 正式リリース + +**代替案 C: Misaki 仕様追従の自動化** + +- Misaki (https://github.com/hexgrad/kokoro 配下の misaki/ja/zh) は upstream が更新される +- GitHub Actions の `schedule` で週次実行: Misaki の特定ファイル(`zh.py` 等)の hash を監視 +- 変更検知で issue 自動作成 + 回帰テストの golden データ再生成 PR 自動作成 +- **本プロジェクトが長期メンテされるなら必須レベルの自動化** +- 手動追従では T01〜T06 相当の工数が毎回発生する + +**代替案 D: 個別パッケージ独立バージョニング** + +- `Directory.Build.props` の `` を廃止し、各 `.csproj` に `` を個別定義 +- 変更のあったパッケージだけバージョンアップ(changesets 風) +- 例: Misaki 対応は `DotNetG2P.Chinese` と `DotNetG2P.Multilingual` のみ v1.10.0、 + 他は v1.9.0 のまま +- メリット: リリースノートが明瞭、ユーザ側の更新追従コスト減 +- デメリット: 依存関係マトリクスが複雑化、`dotnet pack` スクリプトとリリースノート生成の + 再設計が必要 +- **10 パッケージ規模になった現時点で採用を検討する価値がある** + +**代替案 E: Release Please / changesets 等のリリースボット導入** + +- Conventional Commits を採用し、Release Please Action でリリース PR を自動生成 +- コミットメッセージから SemVer 判定、CHANGELOG 生成、バージョン更新 PR 作成まで自動化 +- 本プロジェクトのコミットメッセージは既に日本語 Conventional Commits 風 + (`feat:`, `fix:`, `chore:` 等) なので導入ハードル低 +- T06 のバージョン決定・PR 作成作業を完全自動化できる + +### 6.3 推奨する次世代リリースフロー + +1. Misaki 上流監視を GitHub Actions の `schedule` で自動化(代替案 C) +2. Release Please 導入で PR 作成を自動化(代替案 E) +3. 破壊的変更のない新機能は `-preview.N` を経由(代替案 B) +4. 個別パッケージ独立バージョニングへ段階移行(代替案 D、長期計画) + +--- + +## 7. 後続タスクへの連絡事項 + +本チケットで Mi3 は完了となり、明示的な後続タスクはない。ただし将来チケット化すべき +課題を以下に記録する。 + +### 7.1 Multilingual ファサードの拡張 + +- `MultilingualG2PEngine.ToMisaki(text, Language.Chinese)` は本チケットで提供済み +- 将来の `ToMisaki(text)` 引数なし呼び出し(自動言語判定)では、Misaki 対応言語が + 1 言語(中国語)のみ → 英語/日本語 Kokoro 対応後に自動判定ロジックを整備 + +### 7.2 Misaki v1.1(注音パス)対応 + +- Misaki 上流で注音(Bopomofo)出力パスが追加された場合: + - `DotNetG2P.Chinese` は既に `PinyinStyle.Zhuyin` をサポートしているため、 + `ToMisakiZhuyin()` 追加だけで対応可能なはず + - Mi2 の `MisakiCompatibility` フラグに `ZhuyinMode` を追加する形も検討 +- 代替案 C(6.2 節)の自動監視が動いていれば検知は自動化される + +### 7.3 他言語の Kokoro 互換 + +Kokoro は複数言語をサポート: +- 英語 (`misaki/en.py`) — CMU 辞書ベース → `DotNetG2P.English` の既存実装と近い +- 日本語 (`misaki/ja.py`) — pyopenjtalk/fugashi ベース → `DotNetG2P.Core` の既存実装と近い +- 中国語 (`misaki/zh.py`) — **本チケットで対応済み** +- 韓国語 (`misaki/ko.py`) — `DotNetG2P.Korean` 対応可能 + +**次期マイルストーン案(Mi4〜Mi6):** +- Mi4: 英語 Misaki 互換(CMU 辞書 + Flite LTS → Misaki 音素表への変換層) +- Mi5: 日本語 Misaki 互換(OpenJTalk パイプライン → Misaki 音素表) +- Mi6: 韓国語 Misaki 互換(Hangul-first → Misaki 音素表) + +いずれも T01〜T06 と同様の 6 チケット構成で進める想定。 + +### 7.4 リリース管理の改善(6.2 節より抜粋) + +以下を独立チケットとして追跡推奨: + +- **Infra-1**: Release Please Action 導入調査 & PoC +- **Infra-2**: `tools/bump-version.ps1` スクリプト作成(11 ファイル一括更新) +- **Infra-3**: Misaki upstream 監視ワークフロー(週次 schedule + hash 差分検知) +- **Infra-4**: Kokoro 連携サンプルプロジェクト(`samples/DotNetG2P.Kokoro/`) +- **Infra-5**: 個別パッケージ独立バージョニングへの移行検討 + +### 7.5 Issue #56 クローズ条件 + +- NuGet.org で `DotNetG2P.Chinese 1.10.0` が検索可能になっている +- 質問者から「動作確認 OK」のコメントを受領 +- 受領後に Issue を `Closed (completed)` にする +- 質問者からの返信が 2 週間ない場合はリリース済みを根拠に先行クローズ可(コメントで予告) + +--- + +## 8. 紐づけ + +- **マイルストーン**: Mi3(Misaki 互換中国語出力) +- **依存**: T05(Misaki 互換機能のドキュメント整備・ゴールデンデータ確定) +- **後続**: なし(将来課題は §7 に記録。Mi4 以降は独立マイルストーンとして起票) +- **関連 Issue**: [#56](https://github.com/ayutaz/dot-net-g2p/issues/56) — Misaki 互換中国語出力の要望 +- **関連 PR**: 本チケットで作成する v1.10.0 リリース PR(マージ後に本ドキュメントへ追記) +- **関連ファイル**: + - `.github/workflows/release.yml` — リリースワークフロー + - `.github/workflows/ci.yml` — CI ワークフロー + - `Directory.Build.props` — NuGet バージョン共通定義 + - `src/DotNetG2P.*/package.json` — UPM バージョン(10 ファイル) + - `CLAUDE.md` — 進捗表の更新対象 + - `README.md` — バージョン表記の更新対象 From bd61bacb753fe823b584de40123a69c208a0849b Mon Sep 17 00:00:00 2001 From: yousan Date: Sun, 12 Apr 2026 22:14:23 +0900 Subject: [PATCH 08/21] =?UTF-8?q?docs:=20Phase=201=20=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=20=E2=80=94=20T01/T02=20=E3=81=AE=E3=80=8C?= =?UTF-8?q?=E4=B8=80=E3=81=8B=E3=82=89=E4=BD=9C=E3=82=8A=E7=9B=B4=E3=81=99?= =?UTF-8?q?=E3=81=A8=E3=81=97=E3=81=9F=E3=82=89=E3=80=8D=E3=82=BB=E3=82=AF?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E5=BC=B7=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3エージェント(アーキテクト/マッピング戦略/テスタビリティ)による並列レビューで 以下を追加: T01: - アーキテクトレビュー(PinyinToZhuyinの除外分析、4方式比較、段階的移行工程表) - マッピング戦略再検討(TSV外部化、差分マッピング、Source Generator、6方式比較表) T02: - アーキテクトレビュー(4層分解、4方式C#実装例、record+switch式推奨) - テスタビリティ観点(internal static問題、PinyinConversionResult構造体化、 TryConvert+エラー粒度、Conditionalトレース、InternalsVisibleTo確認) 結論: 現行の独立テーブル方式をT02スコープで維持しつつ、将来の record+switch式 抽象化への移行を可能にする「ファサード維持・エントリ順序統一・Unicode定数化」を 実践的推奨とする。 関連: #56 --- docs/tickets/T01-misaki-mapping-tables.md | 616 ++++++++++++++++++++++ docs/tickets/T02-misaki-convert-method.md | 563 ++++++++++++++++++++ 2 files changed, 1179 insertions(+) diff --git a/docs/tickets/T01-misaki-mapping-tables.md b/docs/tickets/T01-misaki-mapping-tables.md index 7a529aa..93a42bd 100644 --- a/docs/tickets/T01-misaki-mapping-tables.md +++ b/docs/tickets/T01-misaki-mapping-tables.md @@ -383,6 +383,622 @@ Q tɕʰ ʨʰ tɕʰ ㄑ ただし、本チケットのテーブル設計時に差異を明確にドキュメント化し(本チケット自体がその役割を果たす)、将来的にマッピング形式が 6 種以上に増えた場合は A 案(差分テーブル方式)への移行を検討する。 +### マッピング戦略の再検討(追加レビュー) + +本セクションは、マッピング戦略の観点から現行の「独立テーブル×4」方式を再検討し、より保守性の高い代替案を具体化するためのものである。観点は以下の 4 つ: + +1. TSV 外部ファイル化(スキーマ・ロード方式・埋め込みリソース化) +2. 差分マッピング方式(BaseIpaTable + DialectDiff)の具体例 +3. Source Generator によるコンパイル時マッピング生成 +4. 各方式のパフォーマンス・保守性・可読性の比較 + +#### D案: TSV 外部ファイル化の具体案 + +##### スキーマ設計 + +`src/DotNetG2P.Chinese/Data/pinyin_mapping.master.tsv` を単一のマスターファイルとして配置し、全変換形式のエントリを横並びで保持する。 + +```tsv +# pinyin_mapping.master.tsv +# type: initial | final | tone +# key: enum 名(Initial.J, Final.Ai, Tone.First 等) +# ipa: PinyinToIpa の値 +# misaki: PinyinToMisaki の値 +# piper: PinyinToPiperIpa の値 +# zhuyin: PinyinToZhuyin の値 +# comment: Unicode コードポイント・音韻的コメント +type key ipa misaki piper zhuyin comment +initial B p p p ㄅ U+0070 +initial J tɕ ʨ tɕ ㄐ Misaki: U+02A8 (tc ligature) +initial Z ts ʦ ts ㄗ Misaki: U+02A6 (ts ligature) +final Ai aɪ ai̯ aɪ ㄞ Misaki: i + U+032F +final Ao aʊ au̯ aʊ ㄠ Misaki: u + U+032F +final Ong ʊŋ u̯ŋ ʊŋ ㄨㄥ Misaki: u + U+032F + ŋ +tone First ˥˥ → ˥˥ ˉ Misaki: U+2192 +tone Second ˧˥ ↗ ˧˥ ˊ Misaki: U+2197 +``` + +**スキーマ設計ポイント:** + +- **1 行 1 エントリ**: type/key を複合キーとして一意に特定 +- **空セル許容**: zhuyin など一部形式でエントリが存在しない場合は空セル +- **コメント列**: Unicode コードポイント・音韻的メモを保持(レビュー時の視認性向上) +- **UTF-8 BOM なし**: エディタ依存を減らすため BOM なし UTF-8 で統一 +- **`#` 行コメント**: ファイル冒頭で型定義を説明 + +##### ロード方式 + +```csharp +internal static class PinyinMappingTable +{ + // 起動時に 1 度だけロード(lazy initialization) + private static readonly Lazy s_data = new(LoadFromResource); + + private static MappingData LoadFromResource() + { + var asm = typeof(PinyinMappingTable).Assembly; + using var stream = asm.GetManifestResourceStream( + "DotNetG2P.Chinese.Data.pinyin_mapping.master.tsv"); + using var reader = new StreamReader(stream, Encoding.UTF8); + return TsvParser.Parse(reader); + } + + internal static string GetInitial(Initial i, MappingFormat fmt) + => s_data.Value.Initials[(i, fmt)]; +} +``` + +**埋め込みリソース化:** + +```xml + + + + +``` + +他言語パッケージ(Spanish/French/Portuguese)で採用済みの例外辞書 TSV と同一の埋め込み方式を採用することで、プロジェクト全体の一貫性が保たれる。 + +**利点:** + +- マッピング一覧性が最高(全形式を横並びで確認可能) +- 差分が視覚的に明確(差異のあるセルが目立つ) +- 非プログラマ(言語学者・翻訳者)でも編集可能 +- 新形式追加時は列を 1 つ追加するだけ + +**欠点:** + +- 起動時パースコスト(ただし Lazy + 62 エントリで実測 < 1ms) +- TSV 内の Unicode 結合文字(U+032F)はエディタで不可視になりがち → コメント列で補う必要 +- コンパイル時型安全性の喪失(enum 名の typo がランタイムエラーに) + +#### E案: 差分マッピング方式(BaseIpaTable + DialectDiff)の具体例 + +A 案をさらに具体化し、BaseTable/DiffTable 構造を正規化する。 + +```csharp +// 基底テーブル(標準 IPA、すべての形式の起点) +internal static class BaseIpaTable +{ + internal static readonly IReadOnlyDictionary Initials = new Dictionary + { + [Initial.B] = "p", + [Initial.J] = "t\u0255", // tɕ + [Initial.Z] = "ts", + // ... 全 22 エントリ + }; + + internal static readonly IReadOnlyDictionary Finals = new Dictionary + { + [Initial.Ai] = "a\u026A", // aɪ + // ... 全 35 エントリ + }; +} + +// 差分(Misaki 方言) +internal static class MisakiDiff +{ + internal static readonly IReadOnlyDictionary InitialOverrides = new Dictionary + { + [Initial.J] = "\u02A8", // ʨ + [Initial.Q] = "\u02A8\u02B0", + [Initial.Z] = "\u02A6", // ʦ + [Initial.C] = "\u02A6\u02B0", + }; + + internal static readonly IReadOnlyDictionary FinalOverrides = new Dictionary + { + [Final.Ai] = "ai\u032F", + [Final.Ei] = "ei\u032F", + [Final.Ao] = "au\u032F", + [Final.Ou] = "ou\u032F", + [Final.Ong] = "u\u032F\u014B", + [Final.Iao] = "iau\u032F", + [Final.Iu] = "iou\u032F", + [Final.Iong] = "iu\u032F\u014B", + [Final.Uai] = "uai\u032F", + [Final.Ui] = "uei\u032F", + }; + + internal static readonly string[] ToneOverrides = new[] { "", "\u2192", "\u2197", "\u2193", "\u2198" }; +} + +// ルックアップ(差分優先、なければ Base) +internal static class PinyinToMisaki +{ + internal static string GetInitial(Initial i) + => MisakiDiff.InitialOverrides.TryGetValue(i, out var v) ? v : BaseIpaTable.Initials[i]; + + internal static string GetFinal(Final f) + => MisakiDiff.FinalOverrides.TryGetValue(f, out var v) ? v : BaseIpaTable.Finals[f]; + + internal static string GetTone(int t) => MisakiDiff.ToneOverrides[t]; +} +``` + +**起動時マージによる事前計算(パフォーマンス最適化):** + +```csharp +private static readonly IReadOnlyDictionary s_initialMerged = MergeBaseAndDiff( + BaseIpaTable.Initials, MisakiDiff.InitialOverrides); + +private static Dictionary MergeBaseAndDiff( + IReadOnlyDictionary @base, + IReadOnlyDictionary diff) +{ + var result = new Dictionary(@base); + foreach (var kvp in diff) result[kvp.Key] = kvp.Value; + return result; +} +``` + +この事前マージ方式により、ランタイムのルックアップは基底テーブルと同等のコスト(`TryGetValue` 1 回)となる。 + +**利点:** + +- Misaki の差異が 15 エントリ(initial 4 + final 10 + tone 4、軽声除く)のみに集約され、視認性が最高 +- Base の変更が全方言に自動波及(意図した一貫性) +- テストで `Diff.Count` を検証することで「想定外の差分」を検出可能 +- ランタイムオーバーヘッドなし(事前マージ時) + +**欠点:** + +- 方言間の予期せぬ結合(Base 変更の波及)が時に問題になる +- Zhuyin のように全エントリが Base と異なる形式(ラテン→漢字由来記号)では差分方式の利点が消失 +- 「どの値が Base 由来か Diff 由来か」の区別が API 越しには見えない + +#### F案: Source Generator によるコンパイル時マッピング生成 + +C# Source Generator を用いて、TSV ファイルをコンパイル時に読み込み、強型付けされた `static readonly` フィールドを自動生成する。 + +##### 設計 + +```csharp +// Generators/PinyinMappingGenerator.cs +[Generator] +public class PinyinMappingGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // AdditionalFiles から TSV を取得 + var tsvFiles = context.AdditionalTextsProvider + .Where(f => f.Path.EndsWith("pinyin_mapping.master.tsv")); + + context.RegisterSourceOutput(tsvFiles, (spc, file) => + { + var content = file.GetText()?.ToString(); + var entries = ParseTsv(content); + var source = GenerateCode(entries); + spc.AddSource("PinyinMappingTable.g.cs", source); + }); + } +} +``` + +##### 生成される出力例 + +```csharp +// PinyinMappingTable.g.cs (auto-generated) +namespace DotNetG2P.Chinese.Conversion; + +internal static class PinyinToMisakiGenerated +{ + internal static readonly Dictionary Initials = new() + { + [Initial.B] = "p", + [Initial.J] = "\u02A8", + [Initial.Z] = "\u02A6", + // ... 全エントリがコンパイル時に埋め込まれる + }; +} +``` + +##### プロジェクト設定 + +```xml + + + + +``` + +**利点:** + +- TSV の編集容易性と、コンパイル時生成によるランタイム高速性を両立 +- enum 名の typo がコンパイルエラーとして検出される(ジェネレータ側でチェック実装可能) +- 起動時パースコストゼロ(コード生成済み) +- IDE での F12 でジェネレート済みコードへ跳べる(デバッグ容易) +- Base/Diff 関係をジェネレータ内で計算し、最終形式を生成可能 + +**欠点:** + +- **.NET Standard 2.1 ターゲットとの互換性問題**: Source Generator は `netstandard2.0` ターゲットの Generator プロジェクトが必要。Unity IL2CPP ビルドとの相性も要検証 +- ジェネレータプロジェクトの追加によるビルド複雑化 +- デバッグ時の可読性低下(生成コードが見慣れた形と異なる場合) +- 既存の他言語パッケージ(TSV を Runtime ロードしている)との一貫性が崩れる +- Roslyn API の学習コスト(特に Incremental Generator) + +**Unity 互換性の懸念:** + +Unity 2021.2+ の Roslyn バージョンで Incremental Generator が動作するかは要検証。Unity パッケージ側では UPM 経由で配布するため、Generator を同梱しない「ビルド済みコード+TSV リソース」方式のハイブリッドも検討すべき。 + +#### 各方式のパフォーマンス・保守性・可読性比較 + +| 方式 | ランタイム性能 | 起動時コスト | 保守性 | 可読性 | 型安全性 | Unity 互換 | +|------|--------------|------------|--------|--------|---------|-----------| +| **現行(独立テーブル)** | ★★★ (最速) | 無 | ★ (4 箇所同期) | ★ (差分不明瞭) | ★★★ | ★★★ | +| **A案: 差分テーブル(遅延)** | ★★ (TryGet 2 回) | 無 | ★★★ | ★★★ | ★★★ | ★★★ | +| **E案: 差分テーブル(事前マージ)** | ★★★ | 微小 (< 1ms) | ★★★ | ★★★ | ★★★ | ★★★ | +| **B案: 属性方式** | ★ (リフレクション) | 中 (初回のみキャッシュ) | ★★ | ★★ | ★★★ | ★★ (IL2CPP strip 注意) | +| **C/D案: TSV ロード** | ★★★ (ロード後は Dict 参照) | 小 (数ms) | ★★★ | ★★★★ (全形式横並び) | ★ (ランタイム検証) | ★★★ (Embedded Resource) | +| **F案: Source Generator** | ★★★ (最速) | 無 | ★★★ | ★★★ | ★★★ | ★ (要検証) | + +**評価軸の詳細:** + +- **ランタイム性能**: 1 回のルックアップコスト。DictionaryTryGetValue は O(1) なので実際の差は微小だが、TSV 方式は起動後は埋め込み方式と同等 +- **保守性**: マッピング追加・修正時の影響範囲。差分方式と TSV 方式が最良 +- **可読性**: 全形式の差分を一覧する際の容易さ。TSV > 差分 > 独立 +- **型安全性**: enum 名の typo がコンパイル時に検出されるか +- **Unity 互換**: IL2CPP/AOT/Embedded Resource 制約への適合度 + +#### マッピング戦略の推奨(追加レビューの結論) + +**短期(T01/T02 本チケット): 現行の独立テーブル方式を維持する。** 理由は既存の「### 推奨」セクションに記載の通り。ただし以下の追加措置を推奨: + +1. **Unicode 定数クラスの導入**: `Internal/ChineseUnicode.cs` に `NonSyllabicMark = "\u032F"`, `TcLigature = "\u02A8"` 等の名前付き定数を定義し、テーブル定義時に使用する。エスケープシーケンスの散在を防ぎ、レビュー時の誤読を削減する。 + + ```csharp + internal static class ChineseUnicode + { + internal const string NonSyllabicMark = "\u032F"; // COMBINING INVERTED BREVE BELOW + internal const string TcLigature = "\u02A8"; // ʨ + internal const string TsLigature = "\u02A6"; // ʦ + internal const string Aspirated = "\u02B0"; // ʰ + internal const string RightArrow = "\u2192"; // → + // ... + } + ``` + +2. **差分検証テストの追加**: `PinyinToIpa` と `PinyinToMisaki` のテーブルを比較し、差異エントリ数が想定値(initial 4 + final 10 + tone 4 = 18)と一致することを検証するメタテストを追加。想定外の差分を早期検出する。 + +3. **マスター TSV ドキュメント化**: T01 本チケットの表を元に、`docs/chinese/pinyin_mapping_reference.md` として全形式の横並び表を作成・維持する。コードとは独立したドキュメントとし、コード変更時の同期は CI で差分チェックする(目視レビュー)。 + +**中期(5 形式目追加時・例: Kaldi lexicon / LEX 形式等): E 案(差分テーブル・事前マージ)へ移行する。** 理由: + +- 形式数が 5 以上になると独立テーブルの同期コストが許容範囲を超える +- 事前マージ方式ならランタイム性能の劣化なし +- Unity 互換性の懸念なし(通常のコードのみ) +- TSV 方式ほど大掛かりな変更ではなく、段階的移行が容易 + +**長期(10 形式以上・例: 複数 TTS エンジン対応): D 案(TSV 外部ファイル)へ移行する。** 理由: + +- 非プログラマによる編集が可能になり、言語学者の貢献を受け入れやすい +- 他言語パッケージ(Es/Fr/Pt)と一貫した方式となる +- Source Generator(F 案)は Unity 互換性の懸念があるため、ランタイムロード方式(Lazy 初期化)を推奨 + +**Source Generator(F 案)は現時点では採用非推奨。** .NET Standard 2.1 / Unity IL2CPP 環境との互換性検証コストが高く、T01/T02 のスコープを大幅に超える。将来的に Unity が Roslyn Incremental Generator を正式サポートした時点で再検討する。 + +### アーキテクトレビュー(統合的まとめ) + +上記「現行設計の評価」「代替設計案(A/B/C 案)」「推奨」および「マッピング戦略の再検討(D/E/F 案)」は網羅的だが、**ディクショナリ定義の物理構造**にしか注目していない。本節では、「`PinyinToZhuyin` を含めた 4 クラス全体の本質的な構造差」と、「既存レビューで触れられていない C# 言語機能の活用余地」の観点からレビューを補完する。 + +#### 4 クラスの構造分類 — 対称な抽象化の限界 + +現行 4 クラス(追加予定の Misaki 含む)の構造を分類すると、見かけ上「4 つのコピペ」に見える状況は、実は **2 つの異なるパターン** に分離できる。 + +| クラス | キー型 | マッピング構造 | 抽象化対象 | +|-------|-------|--------------|----------| +| `PinyinToIpa` | `Initial`/`Final` enum | 声母/韻母/声調の 3 テーブル | **IPA ファミリ** | +| `PinyinToPiperIpa` | `Initial`/`Final` enum | 声母/韻母の 2 テーブル(声調なし) | **IPA ファミリ** | +| `PinyinToMisaki` (予定) | `Initial`/`Final` enum | 声母/韻母/声調(矢印) | **IPA ファミリ** | +| `PinyinToZhuyin` | `string` (pinyin) | `string`→注音符号 | **文字列変換ファミリ**(別系統) | + +**重要な示唆:** `PinyinToZhuyin` は `Dictionary` でピンイン文字列を直接変換しており、`Initial`/`Final` enum を経由しない。これは音韻論的にも正当で(注音符号は中国語固有の表記で IPA 的な音素分解が不要)、構造的に IPA ファミリとの統一は不自然である。 + +したがって、**抽象化の対象は「IPA ファミリ 3 クラス」に限定すべき**であり、Zhuyin を巻き込む共通化は設計目標として適切ではない。本セクション以降の「共通基盤」は IPA ファミリの話に限る。 + +#### C# 言語機能ベース 4 方式の比較 + +本チケット既存セクション(A/B/C/D/E/F 案)は **テーブル配置戦略** に焦点を当てていたが、ここでは **共通処理の抽象化メカニズム** として C# が提供する 4 つのアプローチを比較する。T02 と共通する論点だが、T01 の視点では「マッピングテーブルをどう型として表現するか」が主題となる。 + +##### 方式 1: interface ベース + +```csharp +internal interface IPinyinMapping +{ + string GetInitial(Initial initial); + string GetFinal(Final final_); + string GetTone(Tone tone); + string RetroflexApical { get; } + string AlveolarApical { get; } +} + +internal sealed class MisakiMapping : IPinyinMapping +{ + private static readonly Dictionary s_initials = /* ... */; + public string GetInitial(Initial i) => s_initials[i]; + // ... +} +``` + +**利点:** モック化が自然(テスト時にフェイク実装注入可能)、依存反転原則(DIP)に忠実、将来の DI 導入と整合。 +**欠点:** インスタンスメソッド呼び出しが仮想メソッドディスパッチになる(JIT 最適化で軽減されるが hot path では計測差が出る)。既存 `internal static` クラス群との様式不整合。 + +##### 方式 2: abstract class ベース + +```csharp +internal abstract class PinyinMappingBase +{ + protected abstract IReadOnlyDictionary Initials { get; } + protected abstract IReadOnlyDictionary Finals { get; } + protected abstract string[] ToneMarkers { get; } + + // 共通ロジックを基底クラスに集約(テンプレートメソッド) + public virtual string Convert(PinyinSyllable s, bool includeTones) + { + // 全クラス共通の変換フロー + } + + // 差異を許容する拡張ポイント + protected virtual string HandleRetroflexApical() => "\u027B\u0329"; + protected virtual string HandleAlveolarApical() => "\u0279\u0329"; +} + +internal sealed class MisakiMapping : PinyinMappingBase +{ + protected override IReadOnlyDictionary Initials => s_initials; + // ... +} +``` + +**利点:** 共通ロジックを基底に集約できる(interface だけでは default interface methods を使わない限り不可能、default interface methods は .NET Standard 2.1 で限定的)。オーバーライドによる柔軟な差分実装が可能。 +**欠点:** 単一継承の制約、`sealed class` でない限りさらなる派生を招きやすい、interface より結合度が高い。 + +##### 方式 3: record + switch 式ベース(推奨候補) + +```csharp +// マッピングを不変の値オブジェクトとして表現 +internal sealed record PinyinMappingTable( + IReadOnlyDictionary Initials, + IReadOnlyDictionary Finals, + IReadOnlyList ToneMarkers, + string RetroflexApical, + string AlveolarApical, + bool IncludeTonesByDefault); + +internal static class PinyinMappingTables +{ + public static readonly PinyinMappingTable Ipa = new( + Initials: BuildIpaInitials(), + Finals: BuildIpaFinals(), + ToneMarkers: new[] { "", "\u02E5\u02E5", "\u02E7\u02E5", "\u02E8\u02E9\u02E6", "\u02E5\u02E9" }, + RetroflexApical: "\u027B\u0329", + AlveolarApical: "\u0279\u0329", + IncludeTonesByDefault: true); + + public static readonly PinyinMappingTable Misaki = Ipa with + { + Initials = new Dictionary(Ipa.Initials) + { + [Initial.J] = "\u02A8", + [Initial.Q] = "\u02A8\u02B0", + [Initial.Z] = "\u02A6", + [Initial.C] = "\u02A6\u02B0", + }, + Finals = new Dictionary(Ipa.Finals) + { + [Final.Ai] = "ai\u032F", + [Final.Ei] = "ei\u032F", + [Final.Ao] = "au\u032F", + [Final.Ou] = "ou\u032F", + [Final.Ong] = "u\u032F\u014B", + [Final.Iao] = "iau\u032F", + [Final.Iu] = "iou\u032F", + [Final.Iong] = "iu\u032F\u014B", + [Final.Uai] = "uai\u032F", + [Final.Ui] = "uei\u032F", + }, + ToneMarkers = new[] { "", "\u2192", "\u2197", "\u2193", "\u2198" }, + }; +} + +// 変換エンジンは record を受け取る純粋関数群 +internal static class PinyinConversionEngine +{ + public static string Convert(PinyinSyllable syllable, PinyinMappingTable table, bool includeTones) + { + // 中央集権的な変換ロジック + var sb = new StringBuilder(16); + + if (syllable.Initial != Initial.None) + { + var skipSemivowel = + (syllable.Initial == Initial.Y || syllable.Initial == Initial.W) + && ShouldOmitSemivowel(syllable.Initial, syllable.Final); + + if (!skipSemivowel) + sb.Append(table.Initials[syllable.Initial]); + } + + if (syllable.Final != Final.None) + { + // そり舌/歯茎母音の分岐も record の値を参照 + var finalStr = (syllable.Final, syllable.Initial) switch + { + (Final.I, var i) when IsRetroflex(i) => table.RetroflexApical, + (Final.I, var i) when IsAlveolar(i) => table.AlveolarApical, + _ => table.Finals[syllable.Final], + }; + sb.Append(finalStr); + } + + if (includeTones && syllable.Tone != Tone.Neutral) + sb.Append(table.ToneMarkers[(int)syllable.Tone]); + + return sb.ToString(); + } +} + +// 既存の内部 API は record を渡す薄いラッパーとして維持(公開 API の互換性保証) +internal static class PinyinToMisaki +{ + public static string Convert(string pinyin) => Convert(pinyin, true); + + public static string Convert(string pinyin, bool includeTones) + { + if (string.IsNullOrEmpty(pinyin)) return string.Empty; + string normalized = ToneConverter.ToToneMarked(pinyin); + if (!PinyinParser.TryParse(normalized, out var syllable)) return string.Empty; + return PinyinConversionEngine.Convert(syllable, PinyinMappingTables.Misaki, includeTones); + } +} +``` + +**利点:** +- **`record` の値セマンティクスで差分記述が自然**: `Ipa with { ... }` 構文で親テーブルからの差分のみを表現できる(既存 A 案「差分テーブル」の型安全版) +- **C# 8.0+ switch 式**でそり舌/歯茎分岐が簡潔に記述でき、分岐の網羅性チェックをコンパイラが行う +- **.NET Standard 2.1 互換**: `record` は C# 9.0 の機能だが、`LangVersion` 指定で .NET Standard 2.1 でも使用可能(DotNetG2P.Chinese の現状設定を確認すること) +- **純粋関数**として `PinyinConversionEngine.Convert` を実装でき、テストが容易(副作用なし、内部状態なし) +- **既存の公開 API 互換性を維持**: `PinyinToIpa.Convert(...)` 等のファサードは薄いラッパーとして残せる +- **テーブル網羅性テストを 1 箇所で書ける**: `PinyinMappingTables.Ipa`, `Misaki`, `PiperIpa` を `IEnumerable` で列挙し、メタテストで全テーブルを同一基準で検証 + +**欠点:** +- `record` の `with` 式は浅いコピーのため、Dictionary の中身を深くコピーする必要がある(上記コードでは明示的に `new Dictionary<...>(Ipa.Initials) { ... }` と書いている) +- `IReadOnlyDictionary` プロパティへのアクセスは仮想呼び出しになるため、`Dictionary<>` 直接参照より数 ns 遅い(実測では意味のない差) + +##### 方式 4: source generator ベース + +既存 F 案で詳述済みのため簡略化するが、T01 視点では「TSV やコード片から `PinyinMappingTable` record を自動生成する」という統合的な使い方が有望。ただし、Unity/IL2CPP 互換性の懸念があるため短期的な採用は見送り。 + +#### 4 方式の比較表(T01 視点) + +| 観点 | 方式 1: interface | 方式 2: abstract class | **方式 3: record+switch** | 方式 4: generator | +|------|----------------|---------------------|-------------------------|-----------------| +| 既存 `internal static` 様式との整合 | 低 | 低 | **中(ファサード維持)** | 高 | +| 差分オーバーライド構文 | 手動 (override) | 手動 (override) | **`with` 式で自然** | コード生成 | +| コンパイル時型安全性 | 中 | 中 | **高 (switch網羅性)** | 高 | +| 既存公開 API 互換 | 要書換 | 要書換 | **維持可** | 維持可 | +| テスト容易性 | 高 (モック) | 中 | **高 (純粋関数)** | 中 | +| ランタイムコスト | 中 (仮想呼び出し) | 中 | **低 (record は class)** | 最低 | +| Unity/IL2CPP 互換 | 高 | 高 | **高** | 要検証 | +| .NET Standard 2.1 互換 | 高 | 高 | **高 (LangVersion 要設定)** | 要検証 | +| リファクタ規模 | 大 | 大 | **中** | 大 | + +#### T01 のマッピング設計への実践的推奨 + +上記の分析を踏まえ、T01 で確定すべきマッピング定義の「書き方」として以下を推奨する。 + +**推奨 1: 現行の独立テーブル方式を踏襲しつつ、将来の `record` 化を見据えた「機械的移行可能」な書式に揃える** + +- マッピングは `private static readonly Dictionary` で宣言する(既存 2 クラスと同一) +- **エントリ順序を `Initial` enum の宣言順に厳密に揃える**(将来 `record PinyinMappingTable` に機械的に変換する際、diff レビューが容易になる) +- 差異エントリには `// Misaki差異: tɕ→ʨ (U+02A8)` の形式でインラインコメントを必須化 + +**推奨 2: Unicode 定数の名前付き化(既存レビューでも言及済み、さらに具体化)** + +```csharp +// src/DotNetG2P.Chinese/Internal/ChineseUnicode.cs +internal static class ChineseUnicode +{ + // IPA 修飾子 + public const string Aspirated = "\u02B0"; // ʰ + public const string SyllabicMark = "\u0329"; // 音節主音記号(下付き) + public const string NonSyllabicMark = "\u032F"; // 非音節化記号(下付き反転ブレーブ) + + // IPA 合字(Misaki 用) + public const string TcLigature = "\u02A8"; // ʨ + public const string TsLigature = "\u02A6"; // ʦ + + // 声調矢印(Misaki 用) + public const string ArrowRight = "\u2192"; // → + public const string ArrowNE = "\u2197"; // ↗ + public const string ArrowDown = "\u2193"; // ↓ + public const string ArrowSE = "\u2198"; // ↘ +} +``` + +これにより、T01 のマッピング定義は: + +```csharp +private static readonly Dictionary s_initialMisaki = new Dictionary +{ + // ... (IPA と同一のエントリは IPA と同じ順序で) + [Initial.J] = ChineseUnicode.TcLigature, // Misaki差異: tɕ→ʨ + [Initial.Q] = ChineseUnicode.TcLigature + ChineseUnicode.Aspirated, // Misaki差異: tɕʰ→ʨʰ + [Initial.Z] = ChineseUnicode.TsLigature, // Misaki差異: ts→ʦ + [Initial.C] = ChineseUnicode.TsLigature + ChineseUnicode.Aspirated, // Misaki差異: tsʰ→ʦʰ + // ... +}; +``` + +と書ける。レビュー時の Unicode 誤読リスクが大幅に減り、将来 `record` 化する際もエントリの意味が明示的に保たれる。 + +**推奨 3: 差分メタテストの導入** + +T01 のテスト項目に以下を追加: + +```csharp +[Fact] +public void MisakiDiffersFromIpa_OnlyAtKnownPositions() +{ + // PinyinToIpa と PinyinToMisaki の全エントリを比較し、 + // 差異のあるエントリ数・位置が想定値と一致することを検証 + var ipaInitials = PinyinToIpa.GetInitialMapSnapshot(); // internal テスト API + var misakiInitials = PinyinToMisaki.GetInitialMapSnapshot(); + + var diffKeys = ipaInitials + .Where(kv => misakiInitials[kv.Key] != kv.Value) + .Select(kv => kv.Key) + .ToHashSet(); + + Assert.Equal( + new HashSet { Initial.J, Initial.Q, Initial.Z, Initial.C }, + diffKeys); +} +``` + +想定外のエントリ差異を早期検出でき、コピペミスや意図せぬ挙動変更をブロックできる。 + +#### アーキテクトレビューとしての最終推奨 + +**T01/T02 のスコープ内では、現行の独立テーブルパターンを踏襲し、上記「推奨 1〜3」を実装する**。理由: + +1. `record + switch` 式の方式 3 は将来の最有力候補だが、3 クラス同時リファクタは Mi1 のスコープ外 +2. 「推奨 1」の**エントリ順序統一**と「推奨 2」の**Unicode 定数化**により、将来の `record` 化が機械的に可能な状態で残せる +3. 「推奨 3」の**差分メタテスト**により、コピペ方式の最大の弱点(テーブル間不整合の見落とし)を補完できる +4. `PinyinToZhuyin` は IPA ファミリとは別系統であり、共通化の対象から除外することで設計目標が明確化される + +**Mi1 完了後の後続タスクとして、別チケット「PinyinConverter 共通抽象化リファクタ」を起票する。** このチケットで: + +- `PinyinMappingTable` record 型の導入 +- `PinyinConversionEngine.Convert(syllable, table, includeTones)` の抽出 +- `PinyinToIpa` / `PinyinToPiperIpa` / `PinyinToMisaki` をファサードに変更 +- 既存 936 件 + Misaki 追加分のテストが全件通過することで安全性を保証 + +この段階的アプローチにより、**現時点では既存パターンを維持してリスクを最小化**しつつ、**将来の構造改善への布石を残す**ことができる。 + ## 7. 後続タスクへの連絡事項 ### T02(Convert メソッド統合)に伝える情報 diff --git a/docs/tickets/T02-misaki-convert-method.md b/docs/tickets/T02-misaki-convert-method.md index fd01571..9c275ec 100644 --- a/docs/tickets/T02-misaki-convert-method.md +++ b/docs/tickets/T02-misaki-convert-method.md @@ -388,6 +388,569 @@ internal static class PinyinConversionHelper ただし、今後さらに出力フォーマットが増える場合は **設計案 B** への移行を検討すべき。4つ以上のフォーマットが並立する段階が移行の判断基準となる。 +### 6.6 テスタビリティ観点の追加レビュー + +上記の設計案 A/B/C はいずれもリファクタリング目線(重複排除)に偏っており、**テスタビリティとデバッグ性** の観点での改善余地が残る。以下、QA 視点での追加レビューと設計案を示す。 + +#### 6.6.1 現状の問題 — internal static パターンのテスト困難性 + +`PinyinToMisaki` は `internal static class` として実装予定であり、`Convert` / `ConvertSyllable` ともに internal/public static メソッドとなる。これは既存の `PinyinToIpa` / `PinyinToPiperIpa` と同じパターンだが、以下のテスタビリティ上の問題を抱える。 + +| 問題 | 具体例 | 影響 | +|------|-------|------| +| **モック化不可** | static メソッドはインターフェースを実装できないため、`ChineseG2PEngine` が `PinyinToMisaki.Convert` を直接呼ぶ箇所をユニットテストでモック差し替えできない | エンジン側テストで Misaki 変換の失敗ケースを再現困難 | +| **中間状態の隠蔽** | `ConvertSyllable` 内の声母出力・韻母出力・声調付与の各ステップが `StringBuilder` に逐次追加されるが、途中結果を観察する手段がない | バグ時にどのステップで誤出力が発生したか特定に時間を要する | +| **エラー情報の欠落** | `Convert()` は null チェック/パース失敗時に無言で `string.Empty` を返す設計。呼び出し側はエラー原因(null、空、パース失敗、未定義声母等)を区別できない | 呼び出し側でログ出力・診断ができない。テスト時もエラーパスの識別が困難 | +| **テスト時の可視性確保** | internal メンバーへのアクセスには `InternalsVisibleTo` が必須。テストアセンブリの追加・削除時に忘れやすい | リファクタ後のテスト疎通までに気付きにくい | + +#### 6.6.2 設計案 D: IPinyinConverter インターフェース + DI(中〜長期) + +internal static から インターフェース + インスタンスベースへ移行する案。 + +```csharp +public interface IPinyinConverter +{ + string Convert(string pinyin, bool includeTones = true); + PinyinConversionResult ConvertDetailed(string pinyin, bool includeTones = true); +} + +internal sealed class PinyinToMisakiConverter : IPinyinConverter { /* 実装 */ } +internal sealed class PinyinToIpaConverter : IPinyinConverter { /* 実装 */ } +internal sealed class PinyinToPiperIpaConverter : IPinyinConverter { /* 実装 */ } +``` + +**メリット:** +- `ChineseG2PEngine` 側テストで `IPinyinConverter` をモック化できる(Moq / NSubstitute 等) +- コンストラクタ DI により、テスト時に Fake 実装を注入可能 +- 設計案 A / B と自然に組み合わせ可能(基底クラスに IPinyinConverter を実装) +- InternalsVisibleTo が不要になる(public インターフェースを介してテスト) + +**デメリット:** +- 既存 `ChineseG2PEngine` が `PinyinToIpa.Convert()` を直接呼んでいるため、フィールド注入+コンストラクタ変更が必要で破壊的 +- Unity 環境では DI コンテナの追加依存を避ける必要がある → 自前でシングルトンファクトリ `PinyinConverterFactory.GetMisaki()` を返す構造が現実的 +- インスタンス化のオーバーヘッド(ただしシングルトン運用で無視できる) + +**コスト/メリット評価:** 中規模リファクタ(Engine 側の呼び出し箇所〜10 箇所程度の書き換え)。T02 のスコープ外だが、Mi1 マイルストーン完了後の技術的負債として別チケット化を推奨。 + +#### 6.6.3 設計案 E: ConvertSyllable の戻り値構造体化(中間状態の公開) + +`ConvertSyllable` の戻り値を単なる `string` から、中間状態を含む構造体に拡張する案。**最小限の変更で最大のデバッグ性向上** が得られる。 + +```csharp +internal readonly struct PinyinConversionResult +{ + public string Output { get; } // 最終文字列(既存互換) + public string InitialPart { get; } // 声母部分 (例: "m") + public string FinalPart { get; } // 韻母部分 (例: "a") + public string TonePart { get; } // 声調矢印/マーカー部分 (例: "↑") + public bool SemivowelOmitted { get; } // Y/W 省略が発生したか + public bool IsRetroflexApical { get; } // そり舌母音分岐に入ったか + public bool IsAlveolarApical { get; } // 歯茎母音分岐に入ったか + public PinyinSyllable SourceSyllable { get; } // 入力音節(デバッグ用) + + public override string ToString() => Output; // 既存コードとの暗黙変換を期待しない +} +``` + +**公開方針:** +- 既存 `Convert(string)` / `Convert(string, bool)` は `string` 戻り値のまま維持(互換性) +- 新規 `ConvertDetailed(string pinyin, bool includeTones)` メソッドを追加し、構造体を返す +- テストコードは `ConvertDetailed` を呼び、`SemivowelOmitted` や `IsRetroflexApical` を直接アサート可能 + +**メリット:** +- 「声母が出力された/されなかった」「どの分岐を通ったか」をテストで直接検証できる(現状は最終文字列から逆推論する必要がある) +- 半母音省略の判定ミス(例: `Final.In` なのに j が付く)を `SemivowelOmitted == true` で明示的に検証可能 +- そり舌/歯茎母音の分岐バグを `IsRetroflexApical` / `IsAlveolarApical` で直接検出可能 +- T03 以降の Engine 側テストでも、「全音節に声調矢印が付いたか」といった集計テストが容易 + +**デメリット:** +- `ConvertSyllable` の内部実装で各部分を個別に追跡する必要がある(StringBuilder 一本書きから分離) +- 構造体のサイズがやや大きめ(string 3 フィールド + bool 3 フィールド)→ ref return や out 引数で回避可能 + +**T02 への組み込み推奨度:** **高**。T02 の実装段階でこの構造を採用すれば、追加コストは小さく、ユニットテストの品質が大きく向上する。 + +#### 6.6.4 設計案 F: エラーハンドリングの粒度強化 + +現状の「失敗時に無言で string.Empty 返却」は、**呼び出し側での診断不能** という重大な欠陥を抱える。以下の TryConvert パターンを追加することを推奨する。 + +```csharp +internal enum PinyinConversionError +{ + None = 0, + NullOrEmpty, // 入力が null または空文字 + ParseFailed, // PinyinParser.TryParse が失敗 + UndefinedInitial, // s_initialMisaki にキーが存在しない + UndefinedFinal, // s_finalMisaki にキーが存在しない + UndefinedToneArrow, // s_toneArrows にインデックスが存在しない +} + +internal static class PinyinToMisaki +{ + public static bool TryConvert( + string pinyin, + bool includeTones, + out string result, + out PinyinConversionError error) + { + if (string.IsNullOrWhiteSpace(pinyin)) + { + result = string.Empty; + error = PinyinConversionError.NullOrEmpty; + return false; + } + // ... 以下、エラー種別を区別しながら処理 + } + + // 既存 API は TryConvert への薄いラッパーとして維持 + public static string Convert(string pinyin, bool includeTones = true) + => TryConvert(pinyin, includeTones, out var r, out _) ? r : string.Empty; +} +``` + +**テスト上のメリット:** +- エラーケースごとに `Assert.Equal(PinyinConversionError.ParseFailed, error)` で明示的に検証可能 +- 「パース失敗」と「未定義声母」はどちらも `string.Empty` を返すが、原因が全く異なる → 現状の API ではテストで区別できない +- 将来 T01 のマッピングテーブルに漏れ(例: 新規韻母追加忘れ)があった場合、`UndefinedFinal` として早期検出できる + +**実装上の注意:** +- `s_initialMisaki` / `s_finalMisaki` のルックアップで `TryGetValue` を使用し、失敗時に `UndefinedInitial` / `UndefinedFinal` を返す +- 現在の `Convert` は Dictionary インデクサ (`s_initialIpa[key]`) を使っており、キー欠落時に `KeyNotFoundException` が発生する可能性がある → T02 では `TryGetValue` への移行を検討 + +#### 6.6.5 設計案 G: デバッグ用トレース出力機能 + +開発時・バグ再現時に `ConvertSyllable` の各ステップの実行状態をコンソール/ログに出力する機能。 + +```csharp +internal static class PinyinToMisaki +{ + // DEBUG ビルドでのみ有効化されるトレースフック + [Conditional("DEBUG")] + internal static void SetTraceWriter(System.IO.TextWriter? writer) + { + s_traceWriter = writer; + } + + private static System.IO.TextWriter? s_traceWriter; + + [Conditional("DEBUG")] + private static void Trace(string step, string detail) + { + s_traceWriter?.WriteLine($"[PinyinToMisaki] {step}: {detail}"); + } + + internal static string ConvertSyllable(PinyinSyllable syllable, bool includeTones) + { + Trace("Input", $"Initial={syllable.Initial}, Final={syllable.Final}, Tone={syllable.Tone}"); + + var sb = new StringBuilder(16); + if (syllable.Initial != Initial.None) + { + if ((syllable.Initial == Initial.Y || syllable.Initial == Initial.W) + && ShouldOmitSemivowel(syllable.Initial, syllable.Final)) + { + Trace("SemivowelOmit", $"{syllable.Initial} omitted for {syllable.Final}"); + } + else + { + var initialStr = s_initialMisaki[syllable.Initial]; + sb.Append(initialStr); + Trace("InitialOut", initialStr); + } + } + // ... 以下同様 + return sb.ToString(); + } +} +``` + +**メリット:** +- `Conditional("DEBUG")` により Release ビルドでは完全に除去される(ゼロコスト) +- テストコードから `StringWriter` を注入してトレース出力をキャプチャし、`Assert.Contains("SemivowelOmit", trace)` のように検証可能 +- バグ報告時に再現コードを渡せば、どのステップで期待外れの挙動が起きたか即座に特定できる + +**デメリット:** +- `[Conditional]` はコンパイル時分岐のため、NuGet パッケージを利用する側(Release ビルド)ではトレースが無効化される → 再現調査には DEBUG ビルドの再実行が必要 +- テストアセンブリ自体は DEBUG ビルドで実行されるため、ユニットテストでのトレース検証は問題なく動作 + +#### 6.6.6 InternalsVisibleTo 設定の確認 + +T02 で `ConvertSyllable` を internal にする場合、既存のテストアセンブリへの可視性を確認する必要がある。 + +**確認事項:** +1. `src/DotNetG2P.Chinese/DotNetG2P.Chinese.csproj` または `AssemblyInfo.cs` に以下の属性があるか: + ```csharp + [assembly: InternalsVisibleTo("DotNetG2P.Tests")] + ``` +2. 既存の `PinyinToIpaTests` / `PinyinToPiperIpaTests` がどのようにテストしているか確認(`public static` を介しているか、`InternalsVisibleTo` を活用しているか) +3. 新規テスト `PinyinToMisakiTests` が `ConvertSyllable(syllable, includeTones)` を直接呼びたい場合、この属性が必須 + +**T02 実装前チェック:** `grep -r "InternalsVisibleTo" src/DotNetG2P.Chinese/` で既存設定を確認し、テストから internal メソッドへの到達経路を事前に保証すること。 + +#### 6.6.7 推奨優先度まとめ + +| 設計案 | T02 への組込 | コスト | メリット | 推奨 | +|--------|------------|--------|---------|------| +| D: IPinyinConverter + DI | スコープ外 | 中 | モック化・疎結合 | 別チケット化 | +| **E: 戻り値構造体化 (ConvertDetailed)** | **T02 内で対応** | **小** | **中間状態のテスト検証** | **強く推奨** | +| **F: TryConvert + エラー列挙** | **T02 内で対応** | **小** | **エラーパスの明示的検証** | **強く推奨** | +| G: Conditional トレース | T02 内で対応可 | 小 | デバッグ時の可視性 | 推奨 | +| D と組み合わせる case | 設計案 B と融合 | 中 | テスト/実装両面で疎結合 | 中期的に検討 | + +**T02 実装時のアクションアイテム:** +1. `ConvertSyllable` の戻り値を `PinyinConversionResult` 構造体化し、`ConvertDetailed` を internal で公開(設計案 E) +2. `TryConvert(out string, out PinyinConversionError)` を追加し、既存 `Convert` はその薄いラッパーとして実装(設計案 F) +3. Dictionary ルックアップを `TryGetValue` に統一し、キー欠落を `UndefinedInitial` / `UndefinedFinal` として返却 +4. `[Conditional("DEBUG")] Trace` フックを配置(設計案 G) +5. `InternalsVisibleTo("DotNetG2P.Tests")` の存在を実装前に確認 +6. ユニットテストで `SemivowelOmitted` / `IsRetroflexApical` / `PinyinConversionError` の各プロパティを明示的にアサートするケースを追加(セクション 4.2 のテスト項目を拡張) + +これらの対応は T02 のスコープ内で実現可能であり、既存の `PinyinToIpa` / `PinyinToPiperIpa` に比べテスタビリティが大きく向上する。同時に、T02 で確立したパターンを既存 2 クラスに逆適用する後続チケット化も視野に入れるべきである。 + +### 6.7 アーキテクトレビュー(統合的まとめ) + +既存 6.1〜6.6 は「共通ヘルパー分離」「インターフェース DI」「戻り値構造体化」「エラー列挙」「トレース」と個別の改善案が並列に提示されているが、**T02 の本質的な課題は「4 クラス目のコピペをどう抽象化するか」**である。本節では C# 言語機能の観点から 4 つの統合的アプローチを比較し、T02 の実装時に採用すべき方針を確定する。 + +#### 6.7.1 現状整理 — 何がコピペなのか + +`PinyinToIpa` / `PinyinToPiperIpa` の構造を具体的に分解すると、以下の 4 層にコピペが発生している: + +| 層 | 内容 | 完全同一か | 差し替え箇所 | +|---|-----|---------|------------| +| L1: 入口処理 | `Convert(string)` の null チェック → `ToneConverter.ToToneMarked` → `PinyinParser.TryParse` → `ConvertSyllable` 委譲 | **完全同一** | なし | +| L2: 分岐制御フロー | `ConvertSyllable` の声母判定 → 半母音省略 → 韻母判定 → そり舌/歯茎分岐 → 声調付与 | **完全同一** | なし | +| L3: 判定ヘルパー | `ShouldOmitSemivowel` / `IsRetroflex` / `IsAlveolar` | **完全同一** | なし | +| L4: マッピングテーブル | `s_initialIpa` / `s_finalIpa` / `s_toneLetters` / `s_retroflexApical` / `s_alveolarApical` | 差異あり | **全行** | + +L1〜L3 は **4 クラスでも完全同一** であり、T02 で 3 クラス目(PinyinToMisaki)を実装すると、L1〜L3 が 3 重コピーになる。本質的に差し替えたいのは L4 のみであり、L1〜L3 は「データを引数として受け取る純粋関数」にすべきである。 + +#### 6.7.2 C# 言語機能ベース 4 方式の比較(T02 視点) + +##### 方式 1: interface ベース(DI/モック化重視) + +既存 6.6.2 の設計案 D で詳述済み。要点は以下: + +```csharp +public interface IPinyinConverter +{ + string Convert(string pinyin, bool includeTones); +} + +internal sealed class PinyinToMisakiConverter : IPinyinConverter +{ + private static readonly Dictionary s_initials = /* ... */; + + public string Convert(string pinyin, bool includeTones) + { + // L1〜L3 の処理を毎回各クラスで書く必要がある + // → default interface methods で共通化可能だが、.NET Standard 2.1 では実装上の制約あり + } +} +``` + +**致命的な欠点:** interface だけでは L1〜L3 の共通ロジックを基底に置けない(default interface methods は .NET Standard 2.1 で限定的にしか使えず、Unity 2021.2 の IL2CPP との相性も不透明)。結局各実装クラスで L1〜L3 を再実装するため、**コピペ問題が解決しない**。 + +##### 方式 2: abstract class ベース(既存 A 案の詳細化) + +```csharp +internal abstract class PinyinConverterBase +{ + // L4: 差し替えたい部分を abstract で公開 + protected abstract IReadOnlyDictionary InitialMap { get; } + protected abstract IReadOnlyDictionary FinalMap { get; } + protected abstract string[] ToneMarkers { get; } + protected abstract string RetroflexApical { get; } + protected abstract string AlveolarApical { get; } + protected virtual bool IncludeTonesByDefault => true; + + // L1: 入口処理(完全共通、override 不要) + public string Convert(string pinyin) => Convert(pinyin, IncludeTonesByDefault); + + public string Convert(string pinyin, bool includeTones) + { + if (string.IsNullOrEmpty(pinyin)) return string.Empty; + string normalized = ToneConverter.ToToneMarked(pinyin); + if (!PinyinParser.TryParse(normalized, out var syllable)) return string.Empty; + return ConvertSyllable(syllable, includeTones); + } + + // L2: 分岐制御フロー(完全共通、override 不要) + protected string ConvertSyllable(PinyinSyllable syllable, bool includeTones) + { + var sb = new StringBuilder(16); + + if (syllable.Initial != Initial.None) + { + var skipSemivowel = + (syllable.Initial == Initial.Y || syllable.Initial == Initial.W) + && ShouldOmitSemivowel(syllable.Initial, syllable.Final); + if (!skipSemivowel) sb.Append(InitialMap[syllable.Initial]); + } + + if (syllable.Final != Final.None) + { + sb.Append((syllable.Final, syllable.Initial) switch + { + (Final.I, var i) when IsRetroflex(i) => RetroflexApical, + (Final.I, var i) when IsAlveolar(i) => AlveolarApical, + _ => FinalMap[syllable.Final], + }); + } + + if (includeTones && syllable.Tone != Tone.Neutral) + sb.Append(ToneMarkers[(int)syllable.Tone]); + + return sb.ToString(); + } + + // L3: 判定ヘルパー(完全共通、基底に集約) + protected static bool ShouldOmitSemivowel(Initial initial, Final final_) { /* ... */ } + protected static bool IsRetroflex(Initial initial) { /* ... */ } + protected static bool IsAlveolar(Initial initial) { /* ... */ } +} + +internal sealed class MisakiConverter : PinyinConverterBase +{ + private static readonly Dictionary s_initials = /* ... */; + private static readonly Dictionary s_finals = /* ... */; + private static readonly string[] s_tones = /* ... */; + + protected override IReadOnlyDictionary InitialMap => s_initials; + protected override IReadOnlyDictionary FinalMap => s_finals; + protected override string[] ToneMarkers => s_tones; + protected override string RetroflexApical => "\u027B\u0329"; + protected override string AlveolarApical => "\u0279\u0329"; +} + +// 既存 API 互換のファサード +internal static class PinyinToMisaki +{ + private static readonly MisakiConverter s_instance = new MisakiConverter(); + public static string Convert(string pinyin) => s_instance.Convert(pinyin); + public static string Convert(string pinyin, bool includeTones) => s_instance.Convert(pinyin, includeTones); +} +``` + +**利点:** +- L1〜L3 の **3 層が完全に基底に集約** され、派生クラスは L4 のマッピングテーブル(5 行の override)のみを書けばよい +- コピペ量が 4 クラス分から 1 クラス分 × 4 マッピングに圧縮される +- 既存 `PinyinToIpa.Convert(...)` 等の公開 API は **ファサード経由で完全維持** できる(破壊的変更なし) +- テスト時は `MisakiConverter` インスタンスを直接生成するか、`PinyinToMisaki` ファサード経由でアクセス +- 派生クラスを `sealed` にすることで、さらなる派生によるバグを防止 + +**欠点:** +- インスタンスメソッド呼び出しのオーバーヘッド(シングルトンで毎回同一インスタンスを返すため実質無視可能) +- 既存 `PinyinToPiperIpa.ConvertToPhonemes`(声母/韻母を配列で返すメソッド)のような**クラス固有拡張**が基底では表現できない → 派生クラスに追加メソッドとして実装する必要あり + +##### 方式 3: record + switch 式ベース(関数型アプローチ) + +方式 2 の「クラス継承」を「record によるデータ表現 + 静的純粋関数」に置き換える。T01 の 6.5.2 方式 3 と同じ考え方を T02 側で実装する。 + +```csharp +// L4: マッピングを不変レコードで表現 +internal sealed record PinyinMappingTable( + IReadOnlyDictionary Initials, + IReadOnlyDictionary Finals, + IReadOnlyList ToneMarkers, + string RetroflexApical, + string AlveolarApical, + bool IncludeTonesByDefault); + +// L1〜L3: 純粋関数として実装(静的クラス) +internal static class PinyinConversionEngine +{ + public static string Convert(string pinyin, PinyinMappingTable table) + => Convert(pinyin, table, table.IncludeTonesByDefault); + + public static string Convert(string pinyin, PinyinMappingTable table, bool includeTones) + { + if (string.IsNullOrEmpty(pinyin)) return string.Empty; + string normalized = ToneConverter.ToToneMarked(pinyin); + if (!PinyinParser.TryParse(normalized, out var syllable)) return string.Empty; + return ConvertSyllable(syllable, table, includeTones); + } + + internal static string ConvertSyllable(PinyinSyllable syllable, PinyinMappingTable table, bool includeTones) + { + var sb = new StringBuilder(16); + + if (syllable.Initial != Initial.None + && !(IsSemivowel(syllable.Initial) && ShouldOmitSemivowel(syllable.Initial, syllable.Final))) + { + sb.Append(table.Initials[syllable.Initial]); + } + + if (syllable.Final != Final.None) + { + sb.Append((syllable.Final, syllable.Initial) switch + { + (Final.I, var i) when IsRetroflex(i) => table.RetroflexApical, + (Final.I, var i) when IsAlveolar(i) => table.AlveolarApical, + _ => table.Finals[syllable.Final], + }); + } + + if (includeTones && syllable.Tone != Tone.Neutral) + sb.Append(table.ToneMarkers[(int)syllable.Tone]); + + return sb.ToString(); + } + + // L3: 判定ヘルパーも純粋関数として static に + private static bool IsSemivowel(Initial i) => i == Initial.Y || i == Initial.W; + private static bool ShouldOmitSemivowel(Initial i, Final f) { /* ... */ } + private static bool IsRetroflex(Initial i) { /* ... */ } + private static bool IsAlveolar(Initial i) { /* ... */ } +} + +// ファサード: マッピングテーブルを引数として渡すだけ +internal static class PinyinToMisaki +{ + private static readonly PinyinMappingTable s_table = PinyinMappingTables.Misaki; + public static string Convert(string pinyin) => PinyinConversionEngine.Convert(pinyin, s_table); + public static string Convert(string pinyin, bool includeTones) => PinyinConversionEngine.Convert(pinyin, s_table, includeTones); +} +``` + +**利点:** +- **継承を使わない**: データ(record)とロジック(static 関数)を完全分離 +- `Ipa with { ... }` 構文で Misaki テーブルを差分定義可能(T01 側と整合) +- **switch 式の網羅性チェック**: コンパイラが `(Final, Initial)` タプルのパターンマッチを検証 +- 純粋関数のためテストが極めて容易(副作用なし、状態なし) +- 既存 `static class` 様式との整合性が最も高い +- 将来の source generator 統合時、TSV からの生成ターゲットは `PinyinMappingTable` のインスタンス初期化のみ → 生成コードが最小化 + +**欠点:** +- 現状 C# LangVersion の確認が必要(`record` は C# 9.0+) +- 引数 `table` を毎回渡す冗長性(実際はファサード経由のため、ユーザーには見えない) + +##### 方式 4: source generator ベース + +T01 の F 案と同様。TSV ファイルからコンパイル時に `PinyinMappingTable` のインスタンス初期化コードを生成する。方式 3 との組み合わせで威力を発揮する(方式 3 が「受け皿」となる)。ただし Unity IL2CPP 互換性検証コストが高く、本チケットでは採用見送り。 + +#### 6.7.3 4 方式の比較表(T02 視点) + +| 観点 | 方式 1: interface | 方式 2: abstract class | **方式 3: record+switch** | 方式 4: generator | +|------|----------------|--------------------|-----------------------|------------------| +| L1〜L3 共通化の実現 | **不可**(DIM 制約) | 可(基底に集約) | **可(純粋関数に集約)** | 可 | +| L4 差し替えの書き心地 | 手動 | 手動 override | **`with` 式で差分** | 自動生成 | +| 既存 `internal static` 様式との整合 | 低 | 中(ファサード経由で維持) | **高(static のまま)** | 高 | +| 既存公開 API 互換 | 要書換 | 維持可(ファサード) | **維持可(ファサード)** | 維持可 | +| 派生クラス固有拡張 (`ConvertToPhonemes`) | 可(インスタンスメソッド追加) | 可(インスタンスメソッド追加) | **可(静的メソッド追加)** | 可 | +| コンパイル時型安全性 | 中 | 中(override 漏れ) | **高(switch 網羅)** | 高 | +| テスト容易性 (L1〜L3 単体テスト) | 中(モック可) | 中(基底のテストが必要) | **高(純粋関数)** | 中 | +| ランタイムコスト | 中(仮想呼び出し) | 中(仮想呼び出し) | **低(static 呼び出し)** | 最低 | +| リファクタ規模 (3 クラス同時改修) | 大 | 中 | **中** | 大 | +| Unity/IL2CPP 互換 | 高 | 高 | **高** | 要検証 | +| 追加形式 5 つ目の工数 | 中 | **小** | **小** | 最小 | + +**方式 3(record + switch 式)が総合的に最有力。** + +#### 6.7.4 既存「設計案 E(戻り値構造体化)」との統合 + +既存 6.6.3 で提案されている `PinyinConversionResult` 構造体(中間状態を含む戻り値)は、方式 3 と自然に統合できる。 + +```csharp +internal readonly record struct PinyinConversionResult( + string Output, + string InitialPart, + string FinalPart, + string TonePart, + bool SemivowelOmitted, + bool IsRetroflexApical, + bool IsAlveolarApical, + PinyinSyllable SourceSyllable) +{ + public static implicit operator string(PinyinConversionResult r) => r.Output; +} + +internal static class PinyinConversionEngine +{ + // 既存互換の string 戻り値版 + public static string Convert(string pinyin, PinyinMappingTable table, bool includeTones) + => ConvertDetailed(pinyin, table, includeTones).Output; + + // 詳細戻り値版 + public static PinyinConversionResult ConvertDetailed(string pinyin, PinyinMappingTable table, bool includeTones) + { + // ... 中間状態を追跡しつつ変換 + } +} +``` + +`readonly record struct` を使うことで、値型の軽量性を保ちつつ `with` 式での部分更新や、コンパイラによる等価性実装を享受できる。 + +#### 6.7.5 T02 実装時の統合的推奨 + +本チケット既存セクション 6.5(現時点での推奨)では「設計案 C(共有ヘルパー分離)」が推奨されているが、アーキテクトレビューの結果、**以下の修正推奨を提示する**。 + +##### 短期(T02 実装時): 設計案 C を採用しつつ、方式 3 への移行準備を整える + +**やること:** + +1. **L3 判定ヘルパーを `Internal/PinyinConversionHelper.cs` に抽出**(既存案 C) + - `ShouldOmitSemivowel`, `IsRetroflex`, `IsAlveolar` を `internal static` メソッドとして配置 + - `PinyinToIpa` / `PinyinToPiperIpa` / `PinyinToMisaki` の 3 クラスから呼び出す + - 既存 2 クラスから重複定義を削除(T02 スコープ内で軽微な書換) + +2. **`PinyinToMisaki.Convert` / `ConvertSyllable` は既存 2 クラスと構造を**完全に揃える** + - エントリの順序、コメント様式、変数名、`StringBuilder` の初期容量まで統一 + - 将来 `PinyinConversionEngine` に抽出する際、3 クラスの `ConvertSyllable` 実装が文字単位で一致するよう書く + - 差異は L4(マッピングテーブル)の参照名のみに集約 + +3. **Dictionary ルックアップを `TryGetValue` に統一**(既存案 F の部分採用) + - `s_initialMisaki[key]` ではなく `s_initialMisaki.TryGetValue(key, out var value)` を使用 + - キー欠落時のフォールバック(例: 空文字返却 or `KeyNotFoundException` 再スロー)を明示化 + - 既存 2 クラスも将来的に同じ方式に揃える + +**やらないこと:** + +- 設計案 E(`PinyinConversionResult` 構造体化)は T02 スコープ外にする。理由: 3 クラス分のリファクタ負荷が大きく、Mi1 マイルストーン全体を遅延させる +- 設計案 F(完全な `TryConvert` エラー列挙)は見送る。理由: エラーパスの詳細化は ChineseG2PEngine レベルで扱うほうが自然で、変換クラス単体での必要性が低い + +##### 中期(Mi1 完了後、別チケット化): 方式 3(record + switch 式)への移行 + +**新規チケット「PinyinConverter 共通抽象化リファクタ」を起票し、以下を実施:** + +1. `PinyinMappingTable` record の導入 +2. `PinyinConversionEngine.Convert(syllable, table, includeTones)` の抽出 +3. `PinyinMappingTables.Ipa` / `PiperIpa` / `Misaki` の定義(`Ipa with { ... }` 構文で差分記述) +4. `PinyinToIpa` / `PinyinToPiperIpa` / `PinyinToMisaki` をファサードに変更(既存 public API は維持) +5. `PinyinToPiperIpa.ConvertToPhonemes` は `PinyinConversionEngine.ConvertToPhonemes(syllable, table)` に一般化 +6. 既存 936 件 + Misaki 追加分のテストが全件通過することで安全性を保証 +7. `PinyinConversionResult` 構造体化(既存案 E)も同時実施し、テスタビリティを向上 + +##### 長期(5 形式目追加時): 方式 4(source generator)への移行を再検討 + +- Unity Roslyn Generator サポート状況を再確認 +- TSV 方式(既存案 D)と組み合わせ、マスターデータの一元管理を実現 +- 方式 3 の `PinyinMappingTable` を受け皿として活用 + +#### 6.7.6 アーキテクチャ上の本質的な教訓 + +本レビューで明確化された本質的な教訓を、今後の設計判断に活用するために記録する: + +1. **「コピペ」は一層ではない。L1〜L4 の各層で異なる抽象化戦略が必要** + - L1(入口処理)と L2(分岐制御)は純粋関数化が最適 + - L3(判定ヘルパー)は static ヘルパークラスが最適 + - L4(マッピングデータ)は record + `with` 式が最適 + +2. **`internal static class` パターンは「値と関数の分離」で抽象化できる** + - インスタンスベース(interface/abstract class)への移行は必須ではない + - データ(record)と関数(static)を分離することで、static 様式を保ったまま抽象化できる + +3. **`PinyinToZhuyin` を抽象化の対象から除外する判断** + - 構造が本質的に異なる(`string` キー、enum 非経由) + - 全てを「1 つのパターンで統一」する必要はない + - 「IPA ファミリ」という部分集合に対する抽象化にとどめるべき + +4. **既存テストが 936 件ある状況でのリファクタは「ファサード保持」が鉄則** + - 内部構造は大胆に変更してよいが、public/internal の API 面は維持する + - ファサードパターンを挟むことで、テストの書き換えを最小化できる + +5. **段階的移行の工程表を明示することで、短期・中期・長期の意思決定を整合させる** + - 「今はやらないが、将来やる」という意思を明示することで、現在の設計判断(コピペ容認)が正当化される + - 「将来の移行を機械的に可能にする」ための現在の制約(エントリ順序統一、命名規則統一)を明示的に課す + ## 7. 後続タスクへの連絡事項 T03(ChineseG2PEngine への ToMisaki API 追加)に伝えるべき情報: From 502c4fa31fe9172c14066cd970ae4e647d2c1971 Mon Sep 17 00:00:00 2001 From: yousan Date: Sun, 12 Apr 2026 22:24:04 +0900 Subject: [PATCH 09/21] =?UTF-8?q?docs:=20Phase=202=20=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=20=E2=80=94=20T03/T04=20=E3=81=AE=E3=80=8C?= =?UTF-8?q?=E4=B8=80=E3=81=8B=E3=82=89=E4=BD=9C=E3=82=8A=E7=9B=B4=E3=81=99?= =?UTF-8?q?=E3=81=A8=E3=81=97=E3=81=9F=E3=82=89=E3=80=8D=E3=82=BB=E3=82=AF?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E5=BC=B7=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3エージェント(API設計/テスト戦略/システム統合)による並列レビューで以下を追加: T03 (+373行): - API設計レビュー(現行31メソッドの爆発問題、戦略パターン+enum OutputFormat 実装例、Fluent API設計、v1.10→v2.0のSemVerロードマップ、他言語命名整合性) - システム統合レビュー(Multilingual層のIMisakiCapableProcessor拡張、 Unity IL2CPP対応、KokoroSharp統合サンプル3種、NuGet/UPM両配布制約、 他言語拡張の命名規則統一) T04 (+738行): - テスト戦略レビュー(TSV駆動テスト完全実装コード、Verify.Xunit Snapshot、 tools/DotNetG2P.MisakiEval ツール設計、既存Piperテスト統一戦略で68%削減案、 KoreanBenchmarkDataLoaderの既存例参照) - システム統合レビュー(MultilingualMisakiIpaTests先行配置、Preserve属性 リフレクション検証、KokoroSharp非依存契約テスト、埋め込み辞書検証、 他言語共通テスト命名規則{Lang}MisakiIpaTests) 主要発見: CapabilityAdapters.cs の IIpaTextBatchProcessor は標準IPAのみ対応で Multilingual経由のMisakiルートが未整備。ChineseG2PEngine.csは既に[Preserve] 付与済みでT03のメソッド追加で自動カバー。 関連: #56 --- docs/tickets/README.md | 4 +- docs/tickets/T03-engine-api-integration.md | 373 +++++++++++ docs/tickets/T04-misaki-tests.md | 738 +++++++++++++++++++++ 3 files changed, 1113 insertions(+), 2 deletions(-) diff --git a/docs/tickets/README.md b/docs/tickets/README.md index 35f8cdf..9b0cdcb 100644 --- a/docs/tickets/README.md +++ b/docs/tickets/README.md @@ -40,6 +40,6 @@ T01 ──► T02 ──► T03 ──► T04 ──► T05 ──► T06 | フェーズ | レビュー対象チケット | レビュー状態 | |---------|---------------------|-------------| -| Phase 1 | T01, T02 | 未実施 | -| Phase 2 | T03, T04 | 未実施 | +| Phase 1 | T01, T02 | **完了** (3エージェント: アーキテクト/マッピング戦略/テスタビリティ) | +| Phase 2 | T03, T04 | **完了** (3エージェント: API設計/テスト戦略/システム統合) | | Phase 3 | T05, T06 | 未実施 | diff --git a/docs/tickets/T03-engine-api-integration.md b/docs/tickets/T03-engine-api-integration.md index bcb5f50..6b70533 100644 --- a/docs/tickets/T03-engine-api-integration.md +++ b/docs/tickets/T03-engine-api-integration.md @@ -409,6 +409,379 @@ var result = engine.Convert("你好") ただし、将来的に出力形式が 10 種類を超える場合は、戦略パターンへのリファクタリングを検討すべき。その際は既存メソッドを `[Obsolete]` にせず、内部で戦略パターンに委譲する形にすれば後方互換性を維持できる。 +### API設計の追加レビュー + +本節は API 設計エンジニア視点での踏み込んだレビューである。§6.1〜§6.4 の方針は現実解として妥当だが、以下の点で不足がある。 + +#### A. 現行記載内容の評価 + +| 項目 | §6 の評価 | 本レビューでの補足 | +|------|----------|--------------------| +| メソッド数の把握 | 「30 メソッド以上」と概数 | 実測: 単一17 + バッチ11 + 辞書参照3 = **31 public メソッド**(`ChineseG2PEngine.cs` L104-L603)。`EnglishG2PEngine` (11 public) と比較して **約3倍**。今回の ToMisakiIpa 追加で 35 メソッドに増える | +| メソッド爆発の原因分析 | 「出力形式ごとに増殖」のみ | 実際には **3軸の直積** — (出力形式 × 引数バリエーション × 単一/バッチ)。例: IPA は (style × includeTones × 単一/List/Batch) = 8通り。この構造に言及がない | +| 命名の揺れ | `ToMisakiIpa` vs `ToMisakiIPA` のみ議論 | 既に **Chinese=`ToPiperIPA` (全大文字) / English=`ToPiperIpa` (PascalCase)** の破綻が存在 (`ChineseG2PEngine.cs:256` vs `EnglishG2PEngine.cs:345`)。パッケージ間の揺れこそが本質問題。§6 はこの既存矛盾に触れていない | +| 戦略パターン案の具体性 | インターフェース定義のみ | `PinyinSyllable` を渡すシグネチャになっているが、現行 `RunPipeline` は `Func` で**文字列のピンイン**を渡している。型が一致せず机上論にとどまっている | +| フルエント API 案 | `ToFormat(OutputFormat.MisakiIpa)` | `enum` ベースだと `ToPinyinList(style)` 型の**配列戻り値**や `ToIpaWithProsody` 型の**構造体戻り値**を統一できない。ジェネリクスや型パラメータの議論が欠落 | +| 段階的導入計画 | なし | 破壊的変更を避けるロードマップ、SemVer 上の位置付け、`[Obsolete]` の扱い方などが未記載 | + +#### B. メソッド爆発を解く具体的な型設計 + +ポイントは「**出力形式** と **出力形状** (string / string[] / ProsodyResult) を分離」することである。現行の Chinese エンジンは両軸を 1 メソッドに畳み込んでいるため爆発している。 + +```csharp +// ===== 出力形式(何に変換するか)===== +// 拡張性: 新形式は enum 追加 + Strategy 実装のみ。エンジン本体の変更不要 +public enum ChineseOutputFormat +{ + PinyinToneMarked, // nǐ hǎo + PinyinNumbered, // ni3 hao3 + PinyinPlain, // ni hao + Ipa, // ni˨˩˦ xɑʊ˨˩˦ + Zhuyin, // ㄋㄧˇ ㄏㄠˇ + PiperIpa, // piper-plus 互換 + MisakiIpa, // ← T03 で追加される形式 + Pua, // piper-plus PUA +} + +// ===== 変換オプション(副次パラメータ)===== +// readonly struct にすることで Unity IL2CPP でのアロケーション/boxing を回避 +public readonly struct ChineseConvertOptions +{ + public bool IncludeTones { get; init; } + public bool RemoveFunctionWordStress { get; init; } // 将来の拡張用 + public string? Separator { get; init; } // null = Engine の既定値 + public static ChineseConvertOptions Default + => new() { IncludeTones = true }; +} + +// ===== ストラテジ(内部 interface、publish 不要)===== +// internal にすれば NuGet の public API サーフェスを汚さない +internal interface IPinyinStringConverter +{ + string Convert(string pinyin, in ChineseConvertOptions options); +} + +internal static class PinyinConverterRegistry +{ + // static readonly 辞書で O(1) ディスパッチ。ジェネリクス未使用 = IL2CPP 安全 + private static readonly Dictionary _map = new() + { + [ChineseOutputFormat.Ipa] = new IpaStrategy(), + [ChineseOutputFormat.Zhuyin] = new ZhuyinStrategy(), + [ChineseOutputFormat.PiperIpa] = new PiperIpaStrategy(), + [ChineseOutputFormat.MisakiIpa] = new MisakiIpaStrategy(), + // ... 他の形式 + }; + + public static IPinyinStringConverter Get(ChineseOutputFormat format) + => _map.TryGetValue(format, out var c) ? c + : throw new NotSupportedException($"Output format {format} is not supported."); +} + +// ===== 具体実装(1 形式 1 クラス = OCP)===== +internal sealed class MisakiIpaStrategy : IPinyinStringConverter +{ + public string Convert(string pinyin, in ChineseConvertOptions options) + => PinyinToMisaki.Convert(pinyin, options.IncludeTones); +} +``` + +エンジン側の公開 API は **1 メソッドで集約**できる: + +```csharp +// 新しい集約 API(1 メソッドで全形式をカバー) +public string Convert(string text, ChineseOutputFormat format) + => Convert(text, format, ChineseConvertOptions.Default); + +public string Convert(string text, ChineseOutputFormat format, in ChineseConvertOptions options) +{ + var strategy = PinyinConverterRegistry.Get(format); + return RunPipeline(text, p => strategy.Convert(p, options)); +} + +// バッチも1メソッド +public IReadOnlyList ConvertBatch(string[] texts, ChineseOutputFormat format) + => ConvertBatch(texts, format, ChineseConvertOptions.Default); + +public IReadOnlyList ConvertBatch(string[] texts, ChineseOutputFormat format, in ChineseConvertOptions options) +{ + ThrowIfDisposed(); + var strategy = PinyinConverterRegistry.Get(format); + var opt = options; // struct キャプチャのため local copy + return BatchConversionHelper.ConvertToList( + texts, + (engine: this, strategy, opt), + (text, ctx) => ctx.engine.RunPipeline(text, p => ctx.strategy.Convert(p, ctx.opt))); +} +``` + +**結果**: 単一/バッチ + 8形式 × (tones付/無) = 旧 32 メソッド → **新 4 メソッド**。Prosody や `string[]` 返しのような **出力形状が異なる API** は無理に統合せず別メソッドとして残す(型システムの限界を素直に受け入れる)。 + +#### C. Fluent / ビルダー API 設計 + +§6.3 の `.ToFormat(...).WithTones(...)` は良いアイデアだが、**「変換を遅延する」ビルダー** ではなく **「設定をチェーンで組み立てる」即時ビルダー** とすべき。中間オブジェクトを `readonly struct` にすれば GC プレッシャを回避できる。 + +```csharp +// Engine の拡張メソッドとして実装(本体を汚さない) +public static class ChineseG2PFluent +{ + public static ChineseConvertRequest For(this ChineseG2PEngine engine, string text) + => new(engine, text, ChineseOutputFormat.PinyinToneMarked, ChineseConvertOptions.Default); +} + +public readonly struct ChineseConvertRequest +{ + private readonly ChineseG2PEngine _engine; + private readonly string _text; + private readonly ChineseOutputFormat _format; + private readonly ChineseConvertOptions _options; + + internal ChineseConvertRequest(ChineseG2PEngine engine, string text, + ChineseOutputFormat format, ChineseConvertOptions options) + { + _engine = engine; + _text = text; + _format = format; + _options = options; + } + + // 形式を切り替える With メソッド群(immutable copy-on-write) + public ChineseConvertRequest AsIpa() => With(ChineseOutputFormat.Ipa); + public ChineseConvertRequest AsZhuyin() => With(ChineseOutputFormat.Zhuyin); + public ChineseConvertRequest AsPiperIpa() => With(ChineseOutputFormat.PiperIpa); + public ChineseConvertRequest AsMisakiIpa()=> With(ChineseOutputFormat.MisakiIpa); + + // オプションの切り替え + public ChineseConvertRequest WithTones(bool include = true) + => With(_options with { IncludeTones = include }); + public ChineseConvertRequest WithoutTones() + => With(_options with { IncludeTones = false }); + public ChineseConvertRequest WithSeparator(string sep) + => With(_options with { Separator = sep }); + + private ChineseConvertRequest With(ChineseOutputFormat f) + => new(_engine, _text, f, _options); + private ChineseConvertRequest With(ChineseConvertOptions o) + => new(_engine, _text, _format, o); + + // 終端(Execute) + public string ToStringResult() => _engine.Convert(_text, _format, _options); + public static implicit operator string(ChineseConvertRequest r) => r.ToStringResult(); +} + +// 使用例: +// string ipa = engine.For("你好").AsMisakiIpa().WithoutTones(); // 暗黙変換 +// string s = engine.For("你好").AsPiperIpa().ToStringResult(); +``` + +**利点**: +- すべて `readonly struct` + immutable chain → GC アロケーションは `string` の boxing 1 回のみ +- IntelliSense で「次に何ができるか」が自然に誘導される +- **既存メソッドと共存可能**(エンジン本体に触れていないため) + +**注意点**: +- `For(text)` が予約語との衝突リスク → `Text(text)` や `Request(text)` も候補 +- バッチ用は `engine.ForBatch(texts).AsMisakiIpa()` のように別エントリポイントを用意する(`IReadOnlyList` 戻りと型が違うため混在不可) + +#### D. 段階的導入計画(SemVer 準拠) + +破壊的変更を避けつつ、**3つのリリースサイクル**で移行する: + +| 段階 | バージョン | 作業内容 | API 破壊 | +|------|-----------|---------|----------| +| **Phase 1** | v1.10.0 (Minor) | `ChineseOutputFormat` enum / `IPinyinStringConverter` / `Convert(text, format, options)` 追加。既存 `ToIPA` / `ToMisakiIpa` 等はそのまま残し、**内部で新 API に委譲**する。T03 の `ToMisakiIpa` もこの形で追加するだけでよい | なし | +| **Phase 2** | v1.11.0 (Minor) | Fluent API (`engine.For(...).AsMisakiIpa()` 等) を追加。XML ドキュメントで Fluent 版を推奨 | なし | +| **Phase 3** | v1.12.0 (Minor) | 既存個別メソッドに `[Obsolete("v2.0 で削除予定。Convert(text, Format.X) を使用してください", error: false)]` を付与。CHANGELOG で周知 | なし(警告のみ) | +| **Phase 4** | v2.0.0 (Major) | Obsolete 済みメソッドを削除。Fluent + `Convert(...)` の2系統のみに集約 | **あり** | + +**ポイント**: +- Phase 1-3 はすべて **Minor** バージョンで完結 → ユーザーコードに影響なし +- Phase 3 で `error: false` にすることで、ビルド警告としてのみ表示される(CI を壊さない) +- `ToMisakiIpa` は Phase 1 の一部として追加される「最後の個別メソッド」と位置付ける + +#### E. 他言語モジュールとの整合性 + +現状の命名不一致を一覧化する: + +| 言語 | IPA | X-SAMPA | PiperIpa | Prosody | +|------|-----|---------|----------|---------| +| Chinese | `ToIPA` | なし | **`ToPiperIPA`** | `ToIpaWithProsody` | +| English | `ToIPA` | `ToXSampa` | **`ToPiperIpa`** | `ToIpaWithProsody` | +| Portuguese | `ToIPA` | `ToXSampa` | — | — | +| Spanish | `ToIPA` | `ToXSampa` | — | — | + +`ToPiperIPA` (Chinese) と `ToPiperIpa` (English) の **大小文字揺れ** が既に存在する。これはコードレビューで見落とされた既存バグであり、T03 で `ToMisakiIpa` を追加する際に **英語側のスタイル (`ToMisakiIpa`, PascalCase)** に合わせることで、新規追加分は正しい方向に揃う。 + +**推奨**: +- 今後の命名規則を **「3文字以上の頭字語は PascalCase」** と明文化(.NET 設計ガイドライン準拠: `Xml`, `Html`, `Ipa`) +- Chinese の `ToPiperIPA` は Phase 3 で `[Obsolete]` 化し、`ToPiperIpa` を正規名として併設 → 他言語と完全一致 +- `IMultilingualG2PEngine` のような **共通インターフェース** を切って `Convert(text, format)` を言語横断で統一することも検討価値あり(ただし Language ごとに format enum が異なるため、ジェネリクス境界が複雑化する点は注意) + +#### F. T03 への即時推奨 + +本チケット (T03) のスコープはあくまで `ToMisakiIpa` 追加のため、上記リファクタはこのチケットでは**行わない**。ただし以下の点は T03 実装時点で配慮すべき: + +1. **命名**: `ToMisakiIpa` (PascalCase) を採用 → 既に §5.4 で決定済みで正解 +2. **型シグネチャ**: 将来 Strategy 実装 (`MisakiIpaStrategy`) に置き換えやすいよう、`PinyinToMisaki.Convert` は引き続き **静的メソッド + ステートレス** を維持する +3. **XML ドキュメント**: `` / `` を追加し、相互参照により IntelliSense でのディスカバリを改善 +4. **新規 enum 追加の先取り**: T03 では不要だが、次のチケット (T05 以降) で `ChineseOutputFormat` 追加を計画に入れておくと、Phase 1 への移行コストが下がる + +以上により、メソッド爆発 / 命名揺れ / 拡張性 の3点を、破壊的変更なしに段階的に解消する道筋が描ける。 + +### システム統合観点の追加レビュー + +本節は、T03 の API 統合設計を「DotNetG2P.Multilingual(多言語ファサード)/Unity UPM/NuGet/KokoroSharp」との連携前提でレビューした結果と、一から作り直す場合の改善案を示す。上記 §API設計の追加レビュー が「クラス内部の型設計」に主眼を置いていたのに対し、本節は **クラスの外** — 上位層・下流ランタイム・配布チャネル — との接続性に焦点を当てる。T03 の成果物は `ChineseG2PEngine` 単体で完結せず、Multilingual / Unity / Kokoro から参照される公開 API となるため、下記観点を追加で検討すべきである。 + +#### A. Multilingual 層への透過的統合(ICapabilityProvider 拡張案) + +現状の `src/DotNetG2P.Multilingual/Internal/CapabilityAdapters.cs` では、`LanguageCapabilityRouter` が `ITextBatchProcessor` と `IIpaTextBatchProcessor` の 2 段階の能力インターフェースで各言語エンジンを薄くラップしている。中国語は `DelegateIpaTextBatchProcessor` に `ToPinyin/ToIPA` の 4 デリゲートを渡して `_primaryProcessors` 辞書に登録されており、他言語と同一パイプラインで動作している。 + +問題点: + +- `IIpaTextBatchProcessor` は「標準 IPA」しか表現できない。`ToMisakiIpa` を ChineseG2PEngine に追加しても、Multilingual 経由で呼び出す経路が存在しない +- Issue #56 の本質は「Kokoro TTS 用の Misaki 互換出力を多言語混在テキストから取得したい」であり、Multilingual 層に Misaki 能力が伝搬しない限り、ユーザーは中英混在テキストを手動でセグメント分割して ChineseG2PEngine を個別にインスタンス化する必要がある +- 将来 Kokoro 対応を英語・日本語に拡張する際、能力インターフェースが爆発する(`IMisakiEnglishTextBatchProcessor` / `IMisakiJapaneseTextBatchProcessor` 等) + +改善案(Mi3 以降で実施、T03 時点では API 命名のみ先行確保): + +```csharp +// 既存: ITextBatchProcessor / IIpaTextBatchProcessor +// 追加: Misaki プロファイル(標準 IPA とは別軸の能力) +internal interface IMisakiCapableProcessor +{ + string ConvertToMisakiIpa(string text, bool includeTones = true); + IReadOnlyList ConvertToMisakiIpaBatch( + IReadOnlyList texts, bool includeTones = true); +} + +// DelegateIpaTextBatchProcessor と同様に DelegateMisakiTextBatchProcessor を追加 +// LanguageCapabilityRouter.CreateLazy に Chinese 用の Misaki デリゲートを注入 +// text => lazyChineseEngine.Value.ToMisakiIpa(text), +// texts => lazyChineseEngine.Value.ToMisakiIpaBatch(texts.ToArray()) +``` + +`LanguageCapabilityRouter.TryGetMisaki(Language, out IMisakiCapableProcessor?)` を追加し、`MultilingualG2PEngine.ToMisakiIpa(string text)` が Chinese セグメントだけを Misaki で処理し、他言語セグメントは現状の IPA でフォールバックする(または将来的に各言語の Misaki 変換で置き換える)。`TryGetMisakiIpa` が `false` を返せばフォールバック経路を明示できる設計にする。 + +**T03 への具体影響**: 本チケットで追加する `ToMisakiIpa` / `ToMisakiIpaBatch` のシグネチャは、後述の `IMisakiCapableProcessor` の契約と 1:1 で対応させること。具体的には: + +- メソッド名は `ToMisakiIpa`(`ToMisakiIPA` ではない)で統一 → 将来の `IMisakiCapableProcessor.ConvertToMisakiIpa` との整合性を担保(これは §API設計の追加レビュー §E の「3文字以上の頭字語は PascalCase」方針とも一致) +- `includeTones` パラメータの既定値は `true` → Kokoro Python の `misaki` デフォルトと一致、`IMisakiCapableProcessor` と揃える +- バッチ版の戻り値は `IReadOnlyList` を維持 → Multilingual の既存契約(`ITextBatchProcessor.ConvertBatch`)と同型なので、将来のアダプタ実装でシグネチャ変換が不要 + +#### B. Unity IL2CPP strip 対策と AOT 互換性 + +`src/DotNetG2P.Chinese/ChineseG2PEngine.cs` は既に `[Preserve]` 属性(`UnityEngine.Scripting.PreserveAttribute` 互換)をクラスレベルで付与済みで(L21)、非 Unity ビルドでは `src/DotNetG2P.Chinese/Internal/PreserveAttribute.cs` のシム(`#if !UNITY_5_3_OR_NEWER` ガード付き)が利用される。T03 で追加する新メソッドは以下を遵守すること: + +1. **メソッド単位の `[Preserve]` は不要**: クラスレベルで `[Preserve]` を付けた場合、Unity IL2CPP リンカーはそのクラスの `public` メソッドをすべて保持対象とする。T03 で追加する 4 メソッドは `public` なのでクラス属性で保護される +2. **`ConvertMisakiIpaBatchItem` 静的ヘルパーには `[Preserve]` 不要**: `private static` かつ `BatchConversionHelper.ConvertToList` から `Func` デリゲート経由で呼び出される。同じアセンブリ内での静的メソッド参照はリフレクションではなくメタデータ参照のため strip されない(ただし、万一 Unity エディタで `link.xml` ベースのアグレッシブな strip 設定がされている環境では、`[Preserve]` を個別付与する保険策も検討に値する) +3. **AOT 警告に注意**: `Func` のようなジェネリックデリゲートは .NET Native AOT でコード生成が必要だが、既存 `ConvertIpaBatchItem` と同型なので新たな警告は発生しない。ただし `true` を csproj に宣言している場合は、T04 のテストで `dotnet publish -r win-x64 --self-contained true /p:PublishAot=true` を 1 回実行して警告有無を確認することを推奨 +4. **トリム警告(IL2026 / IL3050)**: `PinyinToMisaki` が reflection や dynamic code generation を使わない限り、新規警告は発生しない。T02 で実装する `PinyinToMisaki.Convert` がリフレクションベースの辞書ロード(例: `Assembly.GetManifestResourceStream` 経由で辞書を遅延ロード)を含む場合は、`[RequiresUnreferencedCode]` 属性の付与と `ILLink.xml` の更新が必要になる可能性がある +5. **`ILLink.xml` の更新は原則不要**: `[Preserve]` 属性で十分。ただし将来 `IMisakiCapableProcessor` をリフレクションで解決する設計に移行する場合は、`ILLink.xml` に `` を明示することを検討 + +**レビュー項目追加**: T03 実装後、Unity 2022.3 LTS + IL2CPP + iOS/Android Build Target の組み合わせで `engine.ToMisakiIpa("你好")` がランタイムで動作することを簡易確認すること。具体的には UPM パッケージ `com.dotnetg2p.chinese` を含む空の Unity プロジェクトで IL2CPP ビルドし、起動時に例外が出ないことを Log で確認する。Unity Editor の Managed Stripping Level は `Low` / `Medium` / `High` の 3 段階を順に試すことが理想だが、最低でも `Medium`(デフォルト)で通ることを保証する。 + +#### C. KokoroSharp 統合のサンプルコード(ユーザー視点の期待) + +KokoroSharp(https://github.com/Lyrcaxis/KokoroSharp など)は Kokoro TTS モデルの C# 実装で、入力として Misaki 互換 IPA 文字列を想定する。T03 の API がそのまま使える形になっていることが重要である。想定される統合コードは以下: + +```csharp +using DotNetG2P.Chinese; +using KokoroSharp; + +// DotNetG2P 側: Misaki 互換 IPA を取得 +using var g2p = new ChineseG2PEngine(); +string misakiIpa = g2p.ToMisakiIpa("你好世界"); +// 出力例: "ni↗ xau̯↓ ʂɨ↘ ʨie↘" + +// KokoroSharp 側: Misaki IPA を直接フィード +using var tts = new KokoroTTS("kokoro-v1.onnx"); +var audioSamples = tts.Synthesize(misakiIpa, voice: "zf_xiaobei"); +File.WriteAllBytes("output.wav", audioSamples.ToWav()); +``` + +バッチ処理(字幕合成などで複数行を一括変換する場合): + +```csharp +var lines = new[] { "你好", "世界", "再见" }; +IReadOnlyList misakiLines = g2p.ToMisakiIpaBatch(lines); +// 各要素が Misaki 互換 IPA。順序は入力配列と 1:1 対応 +foreach (var ipa in misakiLines) +{ + var clip = tts.Synthesize(ipa, voice: "zf_xiaobei"); + // ... +} +``` + +Multilingual 経由(字幕が中英混在の場合、Mi3 で実装予定の構想): + +```csharp +// 将来の拡張。現時点では Chinese のみ Misaki 対応 +using var multi = new MultilingualG2PEngine(); +if (multi.TryGetMisakiIpa("你好 Hello 世界", out string? misaki)) +{ + // Chinese セグメントは Misaki、English セグメントは標準 IPA or 英語 Misaki + tts.Synthesize(misaki, voice: "zf_xiaobei"); +} +``` + +**T03 への反映**: 上記サンプルが動作するために本チケットで必要な公開 API は `ToMisakiIpa(string)` / `ToMisakiIpaBatch(string[])` の 2 つのみ(既存スコープに含まれる)。ただし XML ドキュメンテーションコメントに「KokoroSharp 等の Kokoro TTS C# 実装にそのまま入力可能」の 1 行を `` に追加すると、IntelliSense で用途が明確になりユーザー体験が向上する。具体的には: + +```csharp +/// +/// テキストを Misaki 互換 IPA 文字列に変換する(声調マーカー付き)。 +/// +/// +/// 出力形式は Python の misaki ライブラリと互換性があり、 +/// KokoroSharp などの Kokoro TTS C# 実装にそのまま入力可能です。 +/// +public string ToMisakiIpa(string text) { ... } +``` + +#### D. NuGet / UPM 両配布における制約 + +`DotNetG2P.Chinese` は NuGet(`DotNetG2P.Chinese`)と UPM(`com.dotnetg2p.chinese`)の両方で配布される。T03 の変更が両配布チャネルで動作するために以下を確認すること: + +1. **NuGet ターゲット**: `.csproj` で `netstandard2.1` ターゲットを維持。T03 の追加メソッドは BCL 標準 API のみ使用するため変更不要 +2. **UPM パッケージ同期**: `Packages/com.dotnetg2p.chinese/Runtime/` 配下に `ChineseG2PEngine.cs` のミラーが存在する場合、`tools/sync-shared-internals.ps1` で同期する必要がある(既存プロジェクトの運用パターン)。新規メソッド追加時は sync スクリプトの対象であることを PR 本文で明記すること +3. **.meta 整合性**: Unity `.meta` ファイルは既存クラスに新メソッドを追加する場合は再生成不要。ただし新規ファイル(例: T02 で `PinyinToMisaki.cs` を別ファイルとして配置)を追加する場合は、`.github/workflows/ci.yml` の .meta 整合性チェック(v1.7.0 で導入)でエラーにならないか確認 +4. **埋め込みリソースの増加**: 本チケットは新規辞書を追加しないため影響なし。ただし将来 Misaki 固有の変換テーブル(例: `misaki_mapping.tsv`)が必要になった場合、UPM の `Resources/` フォルダに配置するのか StreamingAssets 経由にするのかの方針を事前決定すべき(現状の中国語辞書は `Resources.*` 埋め込みリソース方式) +5. **内部型の可視性**: `PinyinToMisaki` クラスは `internal static` のため、NuGet 公開時にも UPM 配布時にも外部 API サーフェスには現れない。`InternalsVisibleTo("DotNetG2P.Tests")` で T04 から直接参照可能にする場合は、`.csproj` に `` を追加すること。ただし T04 の方針(E2E テストのみで間接検証)ではこの追加は不要 + +#### E. 将来の他言語 Kokoro 互換追加に備えた命名・構造 + +Kokoro TTS は中国語以外に日本語・英語・韓国語・スペイン語・フランス語・ポルトガル語・イタリア語・ヒンディー語等に対応する予定がある(2026 年 4 月時点の roadmap)。本 T03 の命名規則が他言語にそのまま展開できる構造になっていることが、将来の拡張コストを大幅に削減する。 + +**推奨命名規則**(Mi3 以降の全言語で統一): + +| 言語 | API 例 | 備考 | +|------|--------|------| +| 中国語 | `ChineseG2PEngine.ToMisakiIpa(string)` | **T03 で実装** | +| 英語 | `EnglishG2PEngine.ToMisakiIpa(string)` | misaki-en の置換規則を適用 | +| 日本語 | `G2PEngine.ToMisakiIpa(string)` | misaki-ja (OpenJTalk ベース) の置換規則 | +| 韓国語 | `KoreanG2PEngine.ToMisakiIpa(string)` | Hangul-first で Misaki 互換 | +| スペイン語 | `SpanishG2PEngine.ToMisakiIpa(string)` | ipa-dict ベースに Kokoro 変換 | +| フランス語 | `FrenchG2PEngine.ToMisakiIpa(string)` | 例外辞書 + Kokoro 変換 | +| ポルトガル語 | `PortugueseG2PEngine.ToMisakiIpa(string)` | 同上 | +| スウェーデン語 | `SwedishG2PEngine.ToMisakiIpa(string)` | Kokoro 未対応だが将来に備え予約 | + +**統一規則**: + +- メソッド名: `ToMisakiIpa` で固定(「Ipa」は PascalCase)。`ToMisakiIPA` は採用しない → T03 の §5.4 の決定を全言語に適用(これは §API設計の追加レビュー §E で指摘された既存の `ToPiperIPA` vs `ToPiperIpa` 揺れ問題の修正方向とも一致) +- オーバーロード: `ToMisakiIpa(string text)` と `ToMisakiIpa(string text, bool includeTones)` の 2 種類を全言語で提供 +- バッチ版: `ToMisakiIpaBatch(string[] texts)` と `ToMisakiIpaBatch(string[] texts, bool includeTones)` +- 名前空間: 各言語パッケージの `Conversion` 名前空間内に `PinyinToMisaki` / `CmuToMisaki` / `KanaToMisaki` / `HangulToMisaki` のような対応クラスを配置(言語ごとに中間表現は異なるが、クラス名の末尾 `ToMisaki` で命名を揃える) + +**T03 の構造的寄与**: 本チケットの実装パターン(`RunPipeline` + 静的変換クラス + `BatchConversionHelper` 利用)は、他言語の Misaki 対応にそのまま再利用可能なテンプレートとなる。コードレビュー時に「この実装パターンが他言語でも再現可能か?」を確認し、困難な箇所があれば T03 の段階で `Conversion/PinyinToMisaki.cs` の命名・責務分離を修正すること。特に `PinyinToMisaki.Convert(pinyin, includeTones)` の静的メソッドシグネチャは、他言語のシングルエントリとして引数型を除いて**完全に同じ形**になる設計を目指す。 + +**避けるべき命名**: + +- `ToKokoroIpa` → Kokoro は TTS 実装名、Misaki は G2P プロセス名なので混同を招く +- `ToIpaForKokoro` → 冗長、読みにくい +- `ToMisakiPhonemes` → 出力は「音素」ではなく IPA 文字列なので不正確(既存の `ToPhonemes` が「音素列」を返すメソッドと衝突する) +- `ToIPA(IpaFormat.Misaki)` のようなオプション経由 → 他言語展開時に `IpaFormat` 列挙型が肥大化し、§API設計の追加レビュー §B の戦略パターン案(`ChineseOutputFormat` enum)と衝突する。Kokoro 互換は独立した "音素系の変換軸" であり、`Format` enum には混ぜないこと + +**§API設計の追加レビュー との整合性**: §B の `ChineseOutputFormat.MisakiIpa` と本節の `ToMisakiIpa` メソッドは、**Phase 1 (v1.10.0)** 時点では共存する設計となる — すなわち `ToMisakiIpa(text)` の内部実装が `Convert(text, ChineseOutputFormat.MisakiIpa)` を呼び出す、という委譲関係になる。この 2 つの視点(クラス内部の型設計 / 外部配布・統合)は矛盾せず、同じ Phase 1 計画のもとで同期的に実装可能である。 + ## 7. 後続タスクへの連絡事項 T04(テスト実装)担当者への伝達事項: diff --git a/docs/tickets/T04-misaki-tests.md b/docs/tickets/T04-misaki-tests.md index 3c75f97..6b644a3 100644 --- a/docs/tickets/T04-misaki-tests.md +++ b/docs/tickets/T04-misaki-tests.md @@ -519,6 +519,744 @@ Portuguese/Spanish パッケージで採用されている方式: **判断**: Mi3 で検討。本 T04 では単一ファイル集約で開始し、保守性に問題が出たら分割する。 +### テスト戦略の追加レビュー + +本節は T04 の「一から作り直すとしたら」セクション (6-1 〜 6-5) をテスト戦略エンジニア視点で再レビューし、現在の記載内容に対する評価と具体的な改善案を追記したものである。 + +#### A. 現在の記載内容の評価 + +| 項目 | 現状記載の評価 | 課題 | +|------|---------------|------| +| 6-1 パラメタライズドテスト中心 | 方向性は正しいが `InlineData` へのベタ書き前提で、データとロジックが混在する設計になっている | テスト追加の度にソース編集が必要、非エンジニアが期待値を更新できない | +| 6-2 TSV データ駆動テスト | Portuguese/Spanish の例に言及されているが「Mi3 で検討」として先送り | T04 時点で `ChinesePiperIpaTests.cs` (512行) 相当のベタ書きが量産されるリスク。後から移行コストが増す | +| 6-3 Misaki 差分レポート自動生成ツール | `tools/DotNetG2P.MisakiEval/` 新規作成の構想があるだけでスコープ外扱い | Python 依存をどう切り離すか、CI で回せる範囲はどこかの議論が欠落 | +| 6-4 Snapshot Testing | 「既存パターンから逸脱」との理由だけで棄却 | Unicode 結合記号 (`U+032F`, `U+0329`) を含む長い期待値には Snapshot が相性抜群であり、機械的棄却は勿体ない | +| 6-5 カテゴリ分割 | 将来検討で妥当 | 判断時期の基準が「保守性に問題が出たら」と曖昧で、誰が判断するか不明 | + +**総合評価**: 「現状維持 + Mi3 送り」の判断が多く、T04 段階での改善余地を捨ててしまっている。既存の `KoreanBenchmarkSeedEvaluationTests.cs` + `KoreanBenchmarkDataLoader.cs` がまさに本ライブラリ内の TSV + MemberData パターンの確立例であり、これを参照しない理由はない。以下で T04 スコープ内で追加すべき具体策を示す。 + +#### B. TSV データ駆動テストの具体的な実装コード + +既存の `KoreanBenchmarkDataLoader` / `PortugueseDatasetEvaluationTests.cs` を手本に、以下の構成を T04 に追加することを提案する。 + +**B-1. TSV ファイル配置** + +``` +tests/TestData/ChineseG2P/ +├── misaki_tones.tsv # 声調矢印マッピング(セクション1相当) +├── misaki_initials.tsv # 声母マッピング(セクション2相当) +├── misaki_finals.tsv # 韻母マッピング(セクション3相当) +├── misaki_apical_vowels.tsv # そり舌/歯茎母音(セクション4相当) +├── misaki_sandhi.tsv # 声調変調(セクション5相当) +├── misaki_issue56.tsv # Issue #56 再現(セクション7相当) +└── README.md # データ生成元・ライセンス・更新手順 +``` + +各 TSV のヘッダ例(6カラム、欠損は空文字): + +```tsv +input expected_equal expected_contains options category notes +妈 ma→ default tone-1 第1声矢印 +麻 ma↗ default tone-2 第2声矢印 +爱 ai̯ default final-ai 二重母音非音節化符号 +你好 ni↗ xau̯↓ default sandhi-3+3 三声連読 +一个 i↗ default yi-sandhi 一+4声→2声変調 +妈 ma include_tones=false tone-off 声調記号抑制 +你好 ni↓ xau̯↓ sandhi=false sandhi-disabled 変調無効化 +``` + +設計ポイント: +- `expected_equal` と `expected_contains` は**排他**(片方だけ使う)。`Assert.Equal` で落ちやすい Unicode 正規化問題を回避するため、基本は `expected_contains` を推奨 +- `options` カラムで `includeTones` や `enableToneSandhi` を切り替え可能にし、セクション5-7, 1-6 を統合 +- `category` は xUnit の Trait 相当で、後でフィルタ実行可能 +- ファイルは UTF-8 (BOM なし) で保存し、CI で BOM 検出を行う + +**B-2. データローダの実装** + +```csharp +// tests/DotNetG2P.Tests/ChineseG2P/MisakiData/MisakiTestCase.cs +namespace DotNetG2P.Tests.ChineseG2P.MisakiData +{ + internal sealed record MisakiTestCase( + string DatasetFileName, + string Input, + string? ExpectedEqual, + string? ExpectedContains, + MisakiTestOptions Options, + string Category, + string Notes); + + internal sealed record MisakiTestOptions( + bool IncludeTones = true, + bool EnableToneSandhi = true) + { + public static MisakiTestOptions Parse(string? raw) + { + if (string.IsNullOrWhiteSpace(raw) || raw == "default") + return new MisakiTestOptions(); + + var includeTones = true; + var sandhi = true; + foreach (var kv in raw.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + var pair = kv.Split('=', 2); + if (pair.Length != 2) continue; + switch (pair[0].Trim()) + { + case "include_tones": + includeTones = bool.Parse(pair[1]); + break; + case "sandhi": + sandhi = bool.Parse(pair[1]); + break; + } + } + return new MisakiTestOptions(includeTones, sandhi); + } + } +} + +// tests/DotNetG2P.Tests/ChineseG2P/MisakiData/MisakiTestCaseLoader.cs +namespace DotNetG2P.Tests.ChineseG2P.MisakiData +{ + internal static class MisakiTestCaseLoader + { + private const string ExpectedHeader = + "input\texpected_equal\texpected_contains\toptions\tcategory\tnotes"; + + private static readonly string[] s_datasetFiles = + { + "misaki_tones.tsv", + "misaki_initials.tsv", + "misaki_finals.tsv", + "misaki_apical_vowels.tsv", + "misaki_sandhi.tsv", + "misaki_issue56.tsv", + }; + + public static IReadOnlyList LoadAllCases() + { + var cases = new List(); + foreach (var fileName in s_datasetFiles) + cases.AddRange(LoadCases(fileName)); + return cases; + } + + public static IReadOnlyList LoadCases(string fileName) + { + var path = ResolveDataPath(fileName); + if (!File.Exists(path)) + throw new FileNotFoundException($"Misaki test data not found: {path}", path); + + var lines = File.ReadAllLines(path); + if (lines.Length == 0 || lines[0] != ExpectedHeader) + throw new InvalidDataException($"Unexpected header in {fileName}: {(lines.Length > 0 ? lines[0] : "(empty)")}"); + + var cases = new List(lines.Length); + for (var i = 1; i < lines.Length; i++) + { + var line = lines[i]; + if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#", StringComparison.Ordinal)) + continue; + + var parts = line.Split('\t'); + if (parts.Length != 6) + throw new InvalidDataException($"Expected 6 columns in {fileName} line {i + 1}, got {parts.Length}"); + + var expectedEqual = string.IsNullOrEmpty(parts[1]) ? null : parts[1]; + var expectedContains = string.IsNullOrEmpty(parts[2]) ? null : parts[2]; + if (expectedEqual == null && expectedContains == null) + throw new InvalidDataException($"Both expected_equal and expected_contains empty at {fileName}:{i + 1}"); + + cases.Add(new MisakiTestCase( + DatasetFileName: fileName, + Input: parts[0], + ExpectedEqual: expectedEqual, + ExpectedContains: expectedContains, + Options: MisakiTestOptions.Parse(parts[3]), + Category: parts[4], + Notes: parts[5])); + } + return cases; + } + + private static string ResolveDataPath(string fileName) + { + var candidates = new[] + { + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", + "tests", "TestData", "ChineseG2P", fileName), + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", + "TestData", "ChineseG2P", fileName), + Path.GetFullPath(Path.Combine("tests", "TestData", "ChineseG2P", fileName)), + }; + foreach (var candidate in candidates) + { + var full = Path.GetFullPath(candidate); + if (File.Exists(full)) return full; + } + return candidates[0]; + } + } +} +``` + +**B-3. MemberData 方式の Theory テスト** + +```csharp +// tests/DotNetG2P.Tests/ChineseG2P/ChineseMisakiIpaTests.cs の一部 +public class ChineseMisakiIpaTests : IDisposable +{ + private readonly ChineseG2PEngine _engine = new(); + private readonly ITestOutputHelper _output; + + public ChineseMisakiIpaTests(ITestOutputHelper output) => _output = output; + + public void Dispose() => _engine.Dispose(); + + // MemberData はカテゴリ別に分割するとログで追跡しやすい + public static IEnumerable TonesCases() + => MisakiTestCaseLoader.LoadCases("misaki_tones.tsv") + .Select(c => new object[] { c }); + + public static IEnumerable InitialsCases() + => MisakiTestCaseLoader.LoadCases("misaki_initials.tsv") + .Select(c => new object[] { c }); + + // ... Finals / ApicalVowels / Sandhi / Issue56 も同様 + + [Theory] + [MemberData(nameof(TonesCases))] + [MemberData(nameof(InitialsCases))] + [MemberData(nameof(FinalsCases))] + [MemberData(nameof(ApicalVowelsCases))] + [MemberData(nameof(SandhiCases))] + [MemberData(nameof(Issue56Cases))] + public void ToMisakiIpa_DataDriven(MisakiTestCase c) + { + var result = _engine.ToMisakiIpa( + c.Input, + includeTones: c.Options.IncludeTones, + enableToneSandhi: c.Options.EnableToneSandhi); + + _output.WriteLine($"[{c.DatasetFileName}/{c.Category}] {c.Input} => {Escape(result)} (expected {Describe(c)})"); + + if (c.ExpectedEqual is not null) + Assert.Equal(c.ExpectedEqual, result); + if (c.ExpectedContains is not null) + Assert.Contains(c.ExpectedContains, result, StringComparison.Ordinal); + } + + private static string Escape(string s) + => string.Concat(s.Select(ch => ch < 0x20 || ch > 0x7E + ? $"\\u{(int)ch:X4}" : ch.ToString())); + + private static string Describe(MisakiTestCase c) + => c.ExpectedEqual is not null + ? $"equal={Escape(c.ExpectedEqual)}" + : $"contains={Escape(c.ExpectedContains!)}"; +} +``` + +注記: +- xUnit v2.9.3 は単一テストメソッドに複数の `[MemberData]` を積めるため、上記の 1 メソッドで 6 カテゴリを網羅できる +- `MisakiTestCase` は `record` (C# 9+) で `IXunitSerializable` 相当の動作を得られる。必要なら `record` 継承クラスに `ToString` を実装し、xUnit のテスト名に期待値が表示されるようにする +- `MemberData` を使うと **xUnit のテストエクスプローラ上で個別のサブテストとして並列実行される** ため、CI 並列化の恩恵を自動で受ける + +**B-4. TSV 追加・レビュー運用** + +- `tests/TestData/ChineseG2P/README.md` に「新規ケース追加手順」「Misaki Python 実装との照合方法」「文字コード要件 (UTF-8, no BOM, LF)」を明記 +- Git の `.gitattributes` で `tests/TestData/ChineseG2P/*.tsv text eol=lf working-tree-encoding=UTF-8` を設定し、Windows での CRLF 混入を防止 +- PR レビュー時は TSV 差分を `git diff --color-words` でレビュアーが見やすい形で確認 + +#### C. Verify.Xunit による Snapshot Testing の適用例 + +セクション 6-4 では「既存パターンから逸脱」として棄却されているが、以下の**限定的な用途**では Snapshot Testing が著しく有効である。 + +**C-1. 適用すべきシナリオ** + +1. **複合シナリオの長文出力**: `"你好世界,我爱北京天安门。"` のような複数音節+句読点混在テキスト。完全一致を `Assert.Equal` で書くと Unicode エスケープで可読性が壊滅する +2. **Issue #56 の完全一致検証**: `"你好"` の 4 パターン (`ToMisakiIpa` / `includeTones=false` / `sandhi=false` / バッチAPI) をまとめて記録 +3. **既存 API への回帰**: `ToIPA` / `ToPiperIPA` / `ToZhuyin` を含む 4 API × 頻出 100 漢字のマトリクスを 1 ファイルにまとめる + +**C-2. 実装例 (Verify.Xunit 28.x 系)** + +```csharp +// tests/DotNetG2P.Tests/DotNetG2P.Tests.csproj に追加: +// + +// tests/DotNetG2P.Tests/ChineseG2P/ChineseMisakiSnapshotTests.cs +[UsesVerify] +public class ChineseMisakiSnapshotTests : IDisposable +{ + private readonly ChineseG2PEngine _engine = new(); + public void Dispose() => _engine.Dispose(); + + [Fact] + public Task Issue56_你好_全パターン() + { + var result = new + { + Default = _engine.ToMisakiIpa("你好"), + NoTones = _engine.ToMisakiIpa("你好", includeTones: false), + NoSandhi = _engine.ToMisakiIpa("你好", enableToneSandhi: false), + BatchDefault = _engine.ToMisakiIpaBatch(new[] { "你好" }).ToArray(), + }; + return Verify(result) + .UseDirectory("Snapshots") + .UseFileName("Issue56_你好"); + } + + [Fact] + public Task APIMatrix_頻出漢字100() + { + var hanzi = new[] { "的", "一", "是", "不", "了", /* ... 100字 */ }; + var matrix = hanzi.Select(h => new + { + Hanzi = h, + Standard = _engine.ToIPA(h), + Piper = _engine.ToPiperIPA(h), + Misaki = _engine.ToMisakiIpa(h), + Zhuyin = _engine.ToZhuyin(h), + }).ToArray(); + return Verify(matrix).UseDirectory("Snapshots"); + } +} +``` + +**C-3. 運用ルール** + +- `Snapshots/Issue56_你好.verified.txt` を Git 管理、`.received.txt` を `.gitignore` に追加 +- 意図的な変更時は `dotnet test --environment Verify.AutoVerify=true` で一括承認 +- CI では `--environment DiffEngine_Disabled=true` を設定して diff ツールの起動を抑制 +- Snapshot の差分レビューは人間必須(自動マージ禁止) + +**C-4. 既存パターンとの共存** + +- 既存の `[Fact]` / `[Theory]` スタイルは維持し、Snapshot は**補完的に**使用 +- 1 ファイル内にのみ Verify 依存を閉じ込めることで、他のテストへの波及を最小化 +- `[UsesVerify]` 属性を付けたクラスだけが Verify を使うため、ライブラリ追加のリスクは限定的 + +**判断案の修正**: 6-4 の「採用見送り」は上記 (C-1) のシナリオに限っては**見直しを推奨**。最低でも Issue #56 の 4 パターン記録は Snapshot の方が明らかに保守性が高い。 + +#### D. Misaki 差分レポート自動生成ツールの設計 + +セクション 6-3 の `tools/DotNetG2P.MisakiEval/` 構想を具体化する。既存の `tools/DotNetG2P.PortugueseEval/` が手本になる。 + +**D-1. ツール全体構成** + +``` +tools/DotNetG2P.MisakiEval/ +├── DotNetG2P.MisakiEval.csproj # net8.0, OutputType=Exe, Core/Chinese 参照 +├── Program.cs # CLI エントリ +├── MisakiCorpusLoader.cs # TSV コーパスのロード +├── MisakiEvaluator.cs # 予測と参照の比較、距離計算 +├── DiffReportWriter.cs # Markdown/TSV/JSON レポート出力 +├── EvalThresholds.cs # misaki_eval_thresholds.json の型 +└── ReferenceProviders/ + ├── IReferenceProvider.cs # 参照音素列の提供抽象 + ├── StaticTsvReferenceProvider.cs # 事前生成 TSV + └── PythonBridgeProvider.cs # Python Misaki 実装を subprocess 呼び出し (開発環境限定) + +tools/ +├── misaki_eval_thresholds.json # データセット毎の PER 閾値 +├── refresh_misaki_eval_data.ps1 # artifacts/misaki-eval/corpora 再生成 +└── run_misaki_full_evaluation.ps1 # フル評価 + レポート生成 +``` + +**D-2. Python 依存の切り離し方針** + +| 段階 | 参照源 | 環境 | 備考 | +|------|-------|------|------| +| 1. 静的 TSV (推奨) | `artifacts/misaki-eval/corpora/misaki_reference_*.tsv` | 任意 | Misaki Python 実装で**事前に**生成した参照を Git 管理外の artifacts に保存。CI でダウンロード | +| 2. Python bridge (開発者向け) | `python -m misaki_cli ...` | Python 環境あり | 開発者のローカル検証用。CI 禁止 | +| 3. テスト統合 (`SkippableFact`) | 静的 TSV が存在する場合のみ動作 | CI 含む全環境 | `PortugueseDatasetEvaluationTests.cs` と同じ `SkippableFact` パターン | + +**D-3. CLI 仕様** + +```bash +# フル評価 +dotnet run --project tools/DotNetG2P.MisakiEval -- \ + --corpus-dir artifacts/misaki-eval/corpora \ + --output-dir artifacts/misaki-eval/reports/$(date -u +%Y%m%d-%H%M%S) \ + --thresholds tools/misaki_eval_thresholds.json \ + --enforce-thresholds + +# 差分比較のみ(閾値無視) +dotnet run --project tools/DotNetG2P.MisakiEval -- \ + --mismatch-limit 100 \ + --categories tone-sandhi,apical-vowel +``` + +**D-4. レポート構造 (出力例)** + +``` +artifacts/misaki-eval/reports/20260412-120000/ +├── summary.tsv # dataset, profile, cases, PER, WER, exact_match_rate +├── summary.json # 上記の JSON 版 +├── mismatches/ +│ ├── frequency_1000__default.tsv # 頻出 1000 字で一致しなかったケース +│ └── sandhi_patterns__default.tsv +├── categories.tsv # カテゴリ別平均距離 (initial / final / tone / sandhi) +└── report.md # 人間可読の日本語レポート (PR コメント投入用) +``` + +`summary.tsv` の列定義: + +```tsv +dataset profile cases exact_match per wer threshold passed +frequency_1000 default 1000 945 0.0082 0.0550 0.01 true +sandhi_patterns default 150 148 0.0031 0.0133 0.01 true +issue56_variants default 4 4 0.0000 0.0000 0.00 true +``` + +**D-5. CI での扱い** + +- **PR CI (必須)**: `tests/DotNetG2P.Tests` 内の `MisakiDatasetEvaluationTests` (`SkippableFact`) を実行。TSV が存在しないときは Skip +- **Nightly (任意)**: `tools/DotNetG2P.MisakiEval` をフル実行し、Markdown レポートを artifact として公開 +- **リリース時**: PER 閾値超過で Red。`misaki_eval_thresholds.json` の変更は別 PR で明示レビュー + +**D-6. Mi3 送りではなく T04 段階で着手すべきか** + +現状 6-3 は「Mi3 で検討」だが、**最小構成 (`IReferenceProvider` + 静的 TSV 1 本 + `summary.tsv` 出力のみ)** なら T04 スコープ内に追加可能。Issue #56 の `"你好"` 4 パターン計測だけでも PR への根拠提示になり、T05 ドキュメント更新の材料になる。 + +#### E. 既存 Piper/IPA テストへの波及 (統一戦略として適用可能か) + +現状 `ChinesePiperIpaTests.cs` (512行) も InlineData ベタ書きで、`ChinesePiperIpaComparisonTests.cs` と合わせて同種の保守性問題を抱えている。本レビューで提案する TSV + MemberData 方式は**統一戦略として横展開可能**である。 + +**E-1. 移行ロードマップ (推奨順)** + +| 順序 | 対象 | 内容 | 対象マイルストーン | +|------|------|------|-------------------| +| 1 | `ChineseMisakiIpaTests` (新規) | TSV 駆動で**最初から**実装 | T04 (本チケット) | +| 2 | `ChinesePiperIpaComparisonTests` | `misaki_standard_piper_diff.tsv` に統合し、3-API 差分を 1 ファイルで表現 | Mi3 | +| 3 | `ChinesePiperIpaTests` | カテゴリ別 TSV (`piper_initials.tsv`, `piper_finals.tsv`, `piper_apical_vowels.tsv`, `piper_edge_cases.tsv`) に移行 | Mi3 | +| 4 | `ChineseG2PEngineC4Tests` (IPA/注音) | `standard_ipa_{initials,finals}.tsv` / `zhuyin_map.tsv` に移行 | Mi4 | +| 5 | `MisakiTestCase` を `ChineseG2PTestCase` に一般化 | `target` カラム (standard/piper/misaki/zhuyin) を追加し、1 つのローダで 4 API すべて扱う | Mi4 以降 | + +**E-2. 共通化する際のポイント** + +- TSV ヘッダを**全 API 共通**に揃える (`input\ttarget\texpected_equal\texpected_contains\toptions\tcategory\tnotes`) +- `target` カラムで `ToIPA` / `ToPiperIPA` / `ToMisakiIpa` / `ToZhuyin` を切り替え +- ローダは `Dictionary>` で API 毎のアダプタを持つ +- テストクラスは API 毎に分離 (`ChineseIpaTests` / `ChinesePiperIpaTests` / `ChineseMisakiIpaTests` / `ChineseZhuyinTests`) するが、**ローダとケース型は共通** にする + +**E-3. 波及による効果 (定量)** + +| 指標 | 現状 (InlineData) | TSV 駆動後 (推定) | +|------|------------------|-----------------| +| `tests/DotNetG2P.Tests/ChineseG2P/*.cs` 総行数 | 約 2,500 行 | 約 800 行 (68% 削減) | +| 新規テストケース追加コスト | C# 編集 + リビルド必須 | TSV 編集のみ | +| 非エンジニアによるレビュー | 困難 | TSV は表形式で容易 | +| CI 並列実行での粒度 | クラス単位 | 個別ケース単位 (MemberData) | +| Unicode エスケープの可読性 | `\u032F` がソース散在 | TSV は実文字で記述可能 | + +**E-4. 横展開のリスクと対策** + +| リスク | 対策 | +|-------|------| +| Mi3/Mi4 で既存テストを書き換えると PR が巨大化 | カテゴリ毎に小 PR に分割。1 PR あたり 1 TSV を原則とする | +| TSV 編集ミスでテストが静かに Skip される | ローダで **件数の下限アサート**を入れる (`Assert.True(cases.Count >= 10)`) | +| xUnit の `MemberData` は `static` メソッドが必要で、`DotNetG2P.Tests` の既存構造を変更しない | `ChineseG2PTestDataLoaders` 静的クラスを `tests/DotNetG2P.Tests/ChineseG2P/TestData/` に新設して吸収 | +| レビュアーが TSV 差分を見落とす | PR テンプレートに「TSV 追加・編集時は diff を本文に貼る」を明記 | + +#### F. 結論とアクションアイテム + +**現状 T04 記載に対する判断の更新提案**: + +| セクション | 現状判断 | 提案 | +|-----------|---------|------| +| 6-1 パラメタライズド | 既に Theory 使用 | 維持 + TSV 併用 | +| 6-2 TSV データ駆動 | Mi3 送り | **T04 で採用** (上記 B 案) | +| 6-3 差分レポートツール | Mi3 送り | **T04 で最小構成を着手** (上記 D-6) | +| 6-4 Snapshot Testing | 棄却 | **Issue #56 限定で採用**再検討 (上記 C-1) | +| 6-5 カテゴリ分割 | Mi3 送り | 維持 (TSV 採用で単一ファイルでも十分) | + +**T04 完了条件に追加すべきアイテム**: + +- [ ] `tests/TestData/ChineseG2P/` に最低 6 本の TSV を作成し、合計 100 ケース以上を収録 +- [ ] `MisakiTestCaseLoader` と `ChineseMisakiIpaTests` を TSV + MemberData 方式で実装 +- [ ] `tests/TestData/ChineseG2P/README.md` にデータ生成元と更新手順を記載 +- [ ] `.gitattributes` に TSV 用のエンコーディング/改行コード設定を追加 +- [ ] (任意) `tools/DotNetG2P.MisakiEval` の最小構成 (静的 TSV ベース) を追加し、`summary.tsv` 出力を確認 +- [ ] (任意) `Verify.Xunit` を参照に追加し、Issue #56 用の Snapshot テスト 1 ファイルのみ作成 + +### システム統合観点の追加レビュー + +本節は、T04 のテスト設計を「DotNetG2P.Multilingual(多言語ファサード)/Unity UPM/NuGet/KokoroSharp」との連携前提でレビューした結果と、テスト統合観点での改善案を示す。上記 §テスト戦略の追加レビュー が「`ChineseG2PEngine` 単体のテスト品質」に主眼を置いていたのに対し、本節は **テスト対象の外** — 上位層・配布チャネル・下流ランタイム — からテストをどう検証するかに焦点を当てる。T04 の成果物は `ChineseG2PEngine.ToMisakiIpa` の正確性だけでなく、それが Multilingual 経由・Unity ランタイム上・KokoroSharp 統合で期待通り動くことを保証する必要がある。 + +#### A. Multilingual 層を経由したテスト(将来の布石) + +現状の T04 スコープ(§2-1〜2-3)は `ChineseG2PEngine` 単体のみを対象にしており、`MultilingualG2PEngine` 経由の Misaki 出力テストは一切含まれていない。これは T03 が Multilingual 層を触らないためで妥当だが、Mi3 で Multilingual 統合が実装された際にテストが後追いになるリスクがある。 + +T04 の段階で以下の「**将来に備えた空テストクラス**」を用意しておくと、Mi3 実装時のテストカバレッジ漏れを防げる: + +```csharp +namespace DotNetG2P.Tests.Multilingual +{ + /// + /// Multilingual 層経由の Misaki 互換出力テスト。 + /// T04 時点では Chinese 単体のみが Misaki 対応しているため、 + /// 多言語混在テキストのテストは Mi3 で有効化する。 + /// + public class MultilingualMisakiIpaTests : IDisposable + { + private readonly MultilingualG2PEngine _engine; + + public MultilingualMisakiIpaTests() + { + _engine = new MultilingualG2PEngine(); + } + + public void Dispose() => _engine.Dispose(); + + [Fact(Skip = "Mi3: Multilingual 層への Misaki 統合実装後に有効化")] + public void ToMisakiIpa_Chinese単独セグメント_Misaki出力() + { + // var result = _engine.ToMisakiIpa("你好"); + // Assert.Contains("\u2197", result); + } + + [Fact(Skip = "Mi3: 中英混在 + フォールバック処理実装後")] + public void ToMisakiIpa_中英混在_Chinese部分のみMisaki_English部分はIPA() + { + // var result = _engine.ToMisakiIpa("你好 Hello 世界"); + // Assert.Contains("ni\u2197", result); // Chinese 部分は Misaki + // Assert.Contains("həloʊ", result); // English 部分は標準 IPA + } + + [Fact(Skip = "Mi3: TryGetMisakiIpa 実装後")] + public void TryGetMisakiIpa_Korean単独_false返却() + { + // Assert.False(_engine.TryGetMisakiIpa("안녕", out _)); + } + } +} +``` + +**判断**: T04 では **`Skip` 属性付きのプレースホルダクラスのみ配置**し、実装は Mi3 に委ねる。これにより: + +1. テストファイルのディレクトリ構造が Mi3 時点で確定し、ファイル作成の PR が不要になる +2. `Skip` 理由に「Mi3」と明記することで、後続担当者が実装順序を把握できる +3. 既存 `MultilingualG2PEngineTests` のパターン(`src/DotNetG2P.Multilingual/MultilingualG2PEngine.cs` を参照するテスト)に合流できる + +#### B. Unity ランタイムでの動作検証テスト + +T04 のテストは `dotnet test DotNetG2P.slnx`(net8.0 環境)でのみ実行される。Unity IL2CPP ビルドで実際にランタイムエラーが起きないかは、通常の xUnit テストでは検出できない。この観点で以下のテストを追加することを推奨する: + +**B-1. `[Preserve]` 属性の存在確認テスト(静的解析)** + +```csharp +[Fact] +public void ChineseG2PEngine_Preserve属性が付与されている() +{ + var type = typeof(ChineseG2PEngine); + var attrs = type.GetCustomAttributes(inherit: false); + var hasPreserve = attrs.Any(a => a.GetType().FullName == "UnityEngine.Scripting.PreserveAttribute"); + Assert.True(hasPreserve, + $"{type.FullName} に [Preserve] が付与されていません。Unity IL2CPP 環境で strip される可能性があります。"); +} + +[Fact] +public void ChineseG2PEngine_ToMisakiIpa_メソッドが公開されている() +{ + var type = typeof(ChineseG2PEngine); + var method = type.GetMethod(nameof(ChineseG2PEngine.ToMisakiIpa), + new[] { typeof(string), typeof(bool) }); + Assert.NotNull(method); + Assert.True(method!.IsPublic); + // public メソッドはクラスレベル [Preserve] により自動保護される +} +``` + +このテストは実装コードの変更ではなくリフレクションによる構造検証のみのため、`DotNetG2P.Tests` (net8.0) でそのまま動く。Unity プロジェクトへの依存は一切ない。 + +**B-2. Unity ビルドスモークテスト(CI 統合)** + +`tests/DotNetG2P.Tests/ChineseG2P/` とは別に、`.github/workflows/ci.yml` にジョブを追加: + +```yaml +unity-il2cpp-smoke: + runs-on: ubuntu-latest + needs: [test] + steps: + - name: Unity IL2CPP ビルド検証 + uses: game-ci/unity-builder@v4 + with: + unityVersion: 2022.3.20f1 + targetPlatform: StandaloneLinux64 + buildMethod: DotNetG2P.Tests.Unity.SmokeBuilder.Build + # Packages/com.dotnetg2p.chinese を含む最小 Unity プロジェクトで + # engine.ToMisakiIpa("你好") を実行し、ランタイム例外が出ないことを確認 +``` + +**判断**: **B-1 は T04 で実装必須**、B-2 は Mi3/Mi4 で検討。B-1 だけでも「`[Preserve]` 属性の外し忘れ」という最頻出の IL2CPP バグを検出できる。 + +#### C. KokoroSharp 統合テストの位置付け + +T03 で追加される `ToMisakiIpa` API は KokoroSharp(または類似の Kokoro TTS C# 実装)から呼び出されることを前提としている。しかし KokoroSharp 自体への依存をテストプロジェクトに追加すると: + +- NuGet 依存関係の複雑化 +- KokoroSharp のバージョン互換性への配慮 +- ONNX モデルファイル(>100MB)のダウンロードと CI での実行 + +等の問題がある。このため T04 では **KokoroSharp を参照しない「契約テスト」** を実装する: + +```csharp +public class KokoroSharpContractTests : IDisposable +{ + private readonly ChineseG2PEngine _engine; + + public KokoroSharpContractTests() + { + _engine = new ChineseG2PEngine(); + } + + public void Dispose() => _engine.Dispose(); + + /// + /// KokoroSharp が期待する入力仕様を満たしていることを確認する契約テスト。 + /// Kokoro TTS のトークナイザ仕様に基づき、以下を検証: + /// 1. 出力は string 型 + /// 2. Unicode IPA + 矢印記号のみで構成される(制御文字なし) + /// 3. セグメント区切りは半角スペース固定 + /// 4. 空入力時は空文字列を返す(例外を投げない) + /// + [Theory] + [InlineData("你好")] + [InlineData("你好世界")] + [InlineData("我爱北京天安门")] + public void ToMisakiIpa_KokoroSharp契約_制御文字を含まない(string input) + { + var result = _engine.ToMisakiIpa(input); + + Assert.NotNull(result); + foreach (var ch in result) + { + var category = CharUnicodeInfo.GetUnicodeCategory(ch); + // Cc (Control), Cf (Format), Cs (Surrogate) を許可しない + Assert.NotEqual(UnicodeCategory.Control, category); + Assert.NotEqual(UnicodeCategory.Format, category); + } + } + + [Fact] + public void ToMisakiIpa_KokoroSharp契約_セグメント区切りはスペース() + { + var result = _engine.ToMisakiIpa("中国"); + // KokoroSharp の tokenizer は " " (0x20) でセグメント分割する + Assert.Contains(" ", result); + } + + [Fact] + public void ToMisakiIpa_KokoroSharp契約_空入力で例外なし() + { + // KokoroSharp は空文字列を「無音」として扱う前提 + var result = _engine.ToMisakiIpa(""); + Assert.Equal("", result); + } +} +``` + +**利点**: + +- KokoroSharp 本体に依存しないため、CI 時間・依存管理が簡素化される +- KokoroSharp 側の仕様変更があった場合、契約テストを更新するだけで追従できる +- 将来 Kokoro Python 実装と互換性比較をする際の SSOT としても機能する + +#### D. NuGet / UPM 両配布のテスト整合性 + +`DotNetG2P.Chinese` は NuGet(`DotNetG2P.Chinese`)と UPM(`com.dotnetg2p.chinese`)の両方で配布されるが、T04 のテストはすべて NuGet / .csproj プロジェクト参照経由で実行される。以下の観点で UPM 配布版の品質を担保する必要がある: + +1. **埋め込みリソースのロード経路**: `EmbeddedChineseDictionaryCache` は `Assembly.GetManifestResourceStream` で辞書をロードするが、Unity UPM 環境では Assembly の扱いが異なる場合がある。T04 で以下のテストを追加: + +```csharp +[Fact] +public void ChineseG2PEngine_デフォルトコンストラクタ_埋め込み辞書をロードできる() +{ + // このテストは NuGet 環境で動くが、UPM 環境でも同じロジックが使われる + using var engine = new ChineseG2PEngine(); + var result = engine.ToMisakiIpa("你好"); + Assert.NotEmpty(result); + // 埋め込み辞書が正しくロードされた証左として、 + // 三声連読変調が適用されていることを確認 + Assert.Contains("\u2197", result); +} +``` + +2. **.meta ファイル整合性**: T04 で `tests/TestData/ChineseG2P/` 配下に TSV を新規作成する場合、Unity UPM パッケージのルートには **含めない**(`tests/` ディレクトリは UPM パッケージに含まれないため問題なし)。ただし `tools/sync-shared-internals.ps1` の同期対象に `.meta` 整合性チェックが含まれる場合は、TSV ファイルが誤って同期されないことを確認 +3. **パッケージ独立性テスト**: `DotNetG2P.Chinese` は `DotNetG2P.Core` を参照しない独立パッケージ。T04 のテストが誤って Core の型(`G2PEngine` 等)に依存していないことを確認: + +```csharp +[Fact] +public void ChineseG2PEngine_Core参照なし_単独で動作する() +{ + // このテストの存在自体が、ChineseG2PEngine が独立パッケージであることの保証 + var assembly = typeof(ChineseG2PEngine).Assembly; + var referencedAssemblies = assembly.GetReferencedAssemblies(); + Assert.DoesNotContain(referencedAssemblies, + a => a.Name == "DotNetG2P" || a.Name == "DotNetG2P.Core"); +} +``` + +#### E. 将来の他言語 Kokoro 互換追加に備えたテスト命名規則 + +T03 §E で他言語の `ToMisakiIpa` 命名規則を統一することを推奨した。T04 のテストファイル命名もこれに揃えることで、Mi3/Mi4 で他言語の Misaki テストを追加する際のレビューコストを下げる: + +**推奨テストクラス命名**: + +| 言語 | テストクラス | 実装タイミング | +|------|------------|----------------| +| 中国語 | `ChineseMisakiIpaTests` | **T04 で実装** | +| 英語 | `EnglishMisakiIpaTests` | Mi3 以降 | +| 日本語 | `JapaneseMisakiIpaTests` | Mi3 以降 | +| 韓国語 | `KoreanMisakiIpaTests` | Mi3 以降 | +| スペイン語 | `SpanishMisakiIpaTests` | Mi3 以降 | +| フランス語 | `FrenchMisakiIpaTests` | Mi3 以降 | +| ポルトガル語 | `PortugueseMisakiIpaTests` | Mi3 以降 | +| Multilingual | `MultilingualMisakiIpaTests` | Mi3 以降(上記 A 節で先行プレースホルダ) | + +**統一規則**: + +- テストクラス名: `{言語名}MisakiIpaTests` で固定 +- 配置: `tests/DotNetG2P.Tests/{言語名}G2P/` 配下(例: `tests/DotNetG2P.Tests/ChineseG2P/ChineseMisakiIpaTests.cs`) +- TSV データ配置: `tests/TestData/{言語名}G2P/misaki_*.tsv` +- コンストラクタ/Dispose パターン: 全言語で統一(`IDisposable` + `_engine` フィールド) +- テストメソッド命名: 既存の日本語命名ルール(例: `ToMisakiIpa_第1声_矢印右向き`)を他言語にも適用 + +**契約テストの共通基底クラス**(Mi3 以降): + +```csharp +// 将来の構想: 全言語で共有される KokoroSharp 契約テスト基底 +public abstract class KokoroSharpContractTestsBase : IDisposable + where TEngine : IDisposable +{ + protected abstract TEngine CreateEngine(); + protected abstract string ConvertToMisakiIpa(TEngine engine, string text); + + [Theory] + [MemberData(nameof(InputSamples))] + public void 契約_制御文字なし(string input) { /* ... */ } + + public static IEnumerable InputSamples => /* 各言語共通のサンプル */; + // Dispose パターン省略 +} +``` + +**T04 での判断**: **基底クラスは T04 では作らず、Mi3 で各言語テストクラスが 2 つ以上できた段階で抽出する**。T04 で先行して作ると YAGNI(You Aren't Gonna Need It)に該当するリスクがある。ただし、T04 で実装する `ChineseMisakiIpaTests` と `KokoroSharpContractTests` のメソッド命名は、後に基底クラスへ抽出しやすい形(`契約_XXX_YYY` プレフィックス)にすること。 + +#### F. T04 完了条件への追加アイテム(本節) + +§F(結論とアクションアイテム)の末尾に、以下を追加で検討する: + +- [ ] `MultilingualMisakiIpaTests.cs` を `[Fact(Skip="Mi3")]` 付きプレースホルダとして作成(上記 A 節) +- [ ] `[Preserve]` 属性存在確認テストを `ChineseG2PEngineTests` に追加(上記 B-1 節) +- [ ] `KokoroSharpContractTests.cs` を新規作成し、制御文字・スペース区切り・空入力契約を検証(上記 C 節) +- [ ] `ChineseG2PEngine_Core参照なし_単独で動作する` テストを追加し、パッケージ独立性を保証(上記 D-3 節) +- [ ] テストクラス命名が `{言語名}MisakiIpaTests` パターンに準拠していることを確認(上記 E 節) + +**§テスト戦略の追加レビュー との整合性**: §B の TSV データ駆動テスト採用案と、本節の Multilingual / Unity / KokoroSharp 契約テスト案は相互補完関係にある。TSV は「言語固有の変換ロジックの正確性」を保証し、本節の統合テストは「クラス間の契約・配布環境・外部ランタイム互換性」を保証する。両者を併用することで T04 の完了条件が堅牢になる。 + --- ## 7. 後続タスクへの連絡事項 From a7030feb52570b8c7b0d3fde4daa474533516274 Mon Sep 17 00:00:00 2001 From: yousan Date: Sun, 12 Apr 2026 22:44:23 +0900 Subject: [PATCH 10/21] =?UTF-8?q?docs:=20Phase=203=20=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=20=E2=80=94=20T05/T06=20=E3=81=AE=E3=80=8C?= =?UTF-8?q?=E4=B8=80=E3=81=8B=E3=82=89=E4=BD=9C=E3=82=8A=E7=9B=B4=E3=81=99?= =?UTF-8?q?=E3=81=A8=E3=81=97=E3=81=9F=E3=82=89=E3=80=8D=E3=82=BB=E3=82=AF?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E5=BC=B7=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3エージェント(ドキュメント戦略/リリース戦略/全体振り返り)による並列レビューで 以下を追加: T05 (+235行): - ドキュメント戦略(DocFXは整備済みだがGitHub Pages公開フロー未定義、 コードXMLDocをSSoTとしたREADME/DocFX自動生成、英語版は逆転発想でXMLDoc 英語SSoT、conventional commits日本語折衷、段階的ロードマップ) - 全体振り返り(Mi1+Mi2統合2フェーズ案、チケット粒度6→3枚再構成、 TSVテスト+ドキュメント並列化30-40%短縮、Phase1/2横断論点8項目のADR化、 Mi4英語Misaki教訓7項目) T06 (+513行): - リリース戦略(release-please+日本語CHANGELOG設定、PublicApiAnalyzer導入、 Nightly+GitHub Packages 3層feed、Misaki上流監視週次ワークフロー、 独立バージョニング7.5人日試算、Infra-1〜7再編) - 全体振り返り(-preview.N先行リリース、軽量リリースフロー、preview版 フィードバック活用、ADR導入5件、教訓9項目、Mi4起票前チェックリスト ADR新設提案) 結論: 次回Mi起票前にT05/T06§Fを統合した「Mi4起票前チェックリスト」を ADRに新設することを推奨。全6チケット+3フェーズレビュー完了。 関連: #56 --- docs/tickets/README.md | 2 +- docs/tickets/T05-documentation-qa.md | 235 ++++++++++++ docs/tickets/T06-release-followup.md | 513 +++++++++++++++++++++++++++ 3 files changed, 749 insertions(+), 1 deletion(-) diff --git a/docs/tickets/README.md b/docs/tickets/README.md index 9b0cdcb..1bbcc9f 100644 --- a/docs/tickets/README.md +++ b/docs/tickets/README.md @@ -42,4 +42,4 @@ T01 ──► T02 ──► T03 ──► T04 ──► T05 ──► T06 |---------|---------------------|-------------| | Phase 1 | T01, T02 | **完了** (3エージェント: アーキテクト/マッピング戦略/テスタビリティ) | | Phase 2 | T03, T04 | **完了** (3エージェント: API設計/テスト戦略/システム統合) | -| Phase 3 | T05, T06 | 未実施 | +| Phase 3 | T05, T06 | **完了** (3エージェント: ドキュメント戦略/リリース戦略/全体振り返り) | diff --git a/docs/tickets/T05-documentation-qa.md b/docs/tickets/T05-documentation-qa.md index 89ba9bb..1e85566 100644 --- a/docs/tickets/T05-documentation-qa.md +++ b/docs/tickets/T05-documentation-qa.md @@ -241,6 +241,241 @@ string[] misakiBatch = zhEngine.ToMisakiIpaBatch(new[] { "你好", "世界" }); 現状の README.md / README_EN.md / README_ZH.md の3ファイル手動管理は更新漏れが起きやすい。テンプレートエンジン(Scriban等)で共通テンプレートから多言語READMEを生成する方式が理想的。 +### ドキュメント戦略の追加レビュー + +本セクションは T05 のスコープ(Misaki 互換中国語 G2P のドキュメント整備)を一段階引き上げ、DotNetG2P プロジェクト全体のドキュメント戦略を「一から作り直すとしたら」どう構築するかを整理した追加レビューである。既存の 6.1〜6.3 の方針を補強しつつ、プロジェクト固有の制約(日本語ファースト、Unity 対応、8 言語 G2P、Kokoro TTS コミュニティとの接点)を踏まえた実装可能な改善案を提示する。 + +#### A. 現状記載(6.1〜6.3)の評価 + +| 観点 | 現状記載の評価 | 不足している点 | +|------|--------------|--------------| +| 6.1 DocFX | docfx によるAPI自動生成の方向性は正しい。現在 `docs/docfx.json` にて 10 パッケージの DLL を metadata ソースとして登録済みで、基盤は既に整っている | (1) README から API テーブルを"除去する"と書いているが、README はクイックスタートの目的もあるため「除去」ではなく「主要 API 5〜10 個に絞り残りは DocFX に誘導」とするのが妥当。(2) `docs/index.md` が英語で書かれているため、日本語ドキュメントとの整合性が取れていない。(3) `CI` で DocFX をビルドしているが、生成物を GitHub Pages に公開する `release.yml` フック、PR プレビューの仕組みが記述されていない | +| 6.2 CHANGELOG | conventional commits + release-please の候補提示は妥当 | (1) 現状 `CHANGELOG.md` がリポジトリルートに存在することが前提だが、実際のファイル有無と現行 `release.yml` との統合方針が不明。(2) 日本語コミットメッセージ運用と conventional commits 英語プレフィックスの混在方針が未整理。(3) NuGet/UPM の両パッケージ系統へのバージョン番号同期について言及なし | +| 6.3 多言語 README | Scriban による生成は理論上可能だが現実的な運用負荷が考慮不足 | (1) README_EN.md / README_ZH.md の"存在は知られているが誰がメンテするか"の責務が未定義。(2) 翻訳自動化(DeepL / LLM)の現実的な選択肢に触れられていない。(3) 8 言語の G2P ライブラリであるのに README が 3 言語のみな理由(需要の実測値)の記載がない | + +全体として方向性は正しいが、**"何を"作るかの提示に留まり"どう運用するか"の実務面が浅い**。以下に具体案を示す。 + +#### B. Single Source of Truth(SSoT)化の具体案 + +現状の情報源泉は 5 系統に分散している: + +1. README.md / README_EN.md / README_ZH.md(ユーザー向けクイックスタート + API 概要) +2. CLAUDE.md(開発者向け進捗・アーキ方針・音素体系) +3. docs/guides/\*.md(設計ドキュメント・マイルストーン計画) +4. docs/index.md + DocFX 生成 API ドキュメント +5. コード内 XML ドキュメントコメント(公開 API の summary / param / returns) + +これらを **XMLDoc を SSoT とする** 形に再編する案: + +**B-1. XMLDoc 拡充と抽出パイプライン** + +- 各 `{Lang}G2PEngine.cs` の public メソッドに以下を完全記載する: + - `` — 1 行要約(README 抜粋用) + - `` — 詳細説明(DocFX 詳細ページ用) + - `` — 実行可能な C# コードブロック(README/DocFX 共用) + - `` / `` — API テーブル生成用 +- `tools/GenerateReadmeSnippets` というビルドタスクを新設し、XMLDoc の `` タグから C# コードブロックを抽出して `docs/_generated/snippets/` へ出力 +- README.md / README_EN.md 内では `` のようなプレースホルダを使い、ビルド時に `_generated/snippets/` の内容へ置換する(Markdown インクルード方式) +- これにより「コード例の変更は XMLDoc の修正だけで全ドキュメントに波及」する構造になり、T05 第 4 節で懸念している「コメント出力例と実装の乖離」を根本解決できる + +**B-2. API テーブルの自動生成** + +- `tools/GenerateApiTable` ビルドタスクを新設し、DocFX の `metadata` ステージで生成される中間 YAML (`api/*.yml`) をパースして、言語パッケージごとに README 用の Markdown テーブル (`docs/_generated/api-tables/{lang}.md`) を出力 +- README.md の「API リファレンス」節は手書きをやめ、`` 形式の include プレースホルダにする +- 現在 T05 第 2.1.4 節で手作業追加している `ToMisakiIpa(text)` 等の行が自動追記されるようになる + +**B-3. 進捗テーブルの CLAUDE.md ↔ README.md 同期** + +- `tools/GenerateProgressTable` により、テスト実行結果(`dotnet test --logger trx`)と csproj のバージョン情報から進捗テーブルを機械生成 +- CLAUDE.md と README.md 両方の進捗テーブルを `` に置換し、手動更新を撤廃 +- T05 第 2.2.1 節のテスト数手動更新(`936` → `936+`)のような曖昧運用を排除する + +**B-4. ビルドタスクとしての統合** + +- `Directory.Build.targets` に `BeforeTargets="Build"` で上記 3 タスクを順序実行 +- `dotnet build` すれば README が自動更新される構造にし、CI の `validation` ジョブで `git diff --exit-code README.md` を実行して生成差分コミット漏れを検出 + +#### C. 英語版ドキュメントの提供戦略 + +現状 README_EN.md / README_ZH.md は存在するが、T05 第 5.1 節が示すように Misaki 機能更新では「T06 で対応する」と後送りされ、更新遅延のリスクが放置されている。DotNetG2P のユーザー層は Kokoro TTS コミュニティ(英語話者比率が高い)を含むため、英語版を「翻訳物」ではなく「一級市民」として扱う戦略が必要。 + +**C-1. 英語を SSoT にする逆転発想** + +- CLAUDE.md 冒頭の「開発言語: 日本語」は維持しつつ、**コード内 XMLDoc だけは英語 SSoT** とする例外ルールを設ける +- 理由: (1) DocFX が生成する API リファレンスは国際標準として英語が適切、(2) NuGet.org / nuget-packages 検索の SEO が英語前提、(3) Kokoro TTS コミュニティへの導線となる +- 日本語コメント・日本語 README のコンテンツは英語 XMLDoc から LLM または DeepL API で生成する逆方向パイプラインにする +- これにより「英語版 README は翻訳待ち」という現状の課題が解消される + +**C-2. 翻訳自動化パイプライン** + +- `tools/TranslateDocs` というユーティリティを作成し、以下の翻訳戦略を段階的に検討: + - **段階 1: DeepL API (Pro)** — 技術用語の精度が高い。月 50 万文字まで有料プラン。README 規模(3 言語 × 600 行)なら月 \$10 程度でカバー + - **段階 2: OpenAI API / Anthropic API** — プロンプトで「音声学専門用語は保持、`ToMisakiIpa` 等のコードは翻訳しない」と制御可能。コスト \$5〜20/月 + - **段階 3: ローカル LLM (Qwen2.5-7B 等)** — コスト 0 だが品質は要評価 +- 翻訳対象は `` / `` マーカーで区切り、コードブロック・表・リンクはスキップ +- CI で翻訳差分を PR として自動作成(`github-actions[bot]` が `docs: auto-translate README to EN/ZH` PR を作成) +- 日本語プロジェクト維持方針を守りつつ、英語・中国語版の更新遅延をゼロにできる + +**C-3. 言語カバレッジの段階的拡大** + +- 第 1 フェーズ: 既存の日/英/中 3 言語を継続(ただし自動翻訳化) +- 第 2 フェーズ: Kokoro TTS コミュニティで需要が高い言語(韓国語・スペイン語)を追加。ライブラリが G2P 対応する言語と README 対応言語の一致は親和性が高い +- 第 3 フェーズ: G2P 対応 8 言語全てに README を展開(自動翻訳のため実装コストは一定) + +#### D. DocFX API doc からのサンプルコード自動抽出 + +T05 第 6.1 節の `[Fact]` の DisplayName / InlineData からサンプル抽出という方向性を具体化: + +**D-1. xUnit テストからのサンプル抽出** + +- `tests/DotNetG2P.Tests/` 配下のテストに `[Trait("DocSample", "chinese.misaki-ipa")]` のようなタグを付与 +- `tools/ExtractTestSamples` ツールが MSTest の test assembly を Reflection で読み、`DocSample` トレイト付きテストから: + - `[InlineData("你好", "ni↗xau̯↓")]` などの引数 → `input = "你好", expected = "ni↗xau̯↓"` の形で抽出 + - テストメソッド本体の 1 行分を「使用例」として抽出(`engine.ToMisakiIpa("你好")` の行) +- 抽出結果を `docs/_generated/examples/chinese.misaki-ipa.md` に出力し、README と DocFX に include する + +**D-2. DocFX `` タグとの統合** + +- `` タグ内に `` で C# コードを記述すると、DocFX はその内容を構文ハイライト付きで API ページに自動埋め込みする +- `tools/ExtractTestSamples` の出力を XMLDoc 形式に整形するモードを設け、csproj ビルド前に `obj/GeneratedDocComments/` へ追加 XMLDoc を出力、`DocumentationFile` プロパティで統合する +- これにより「テストがパスしている = ドキュメントのサンプル出力値が正確」という保証が得られる + +**D-3. Verify/Snapshot との連携(オプション)** + +- `Verify.Xunit` パッケージ導入でスナップショット駆動の期待値管理が可能 +- `.verified.txt` ファイルをドキュメントの「期待出力」として DocFX に include すれば、Misaki Python との出力比較(T05 第 4.4 節)も自動化できる + +#### E. Conventional Commits + release-please による CHANGELOG 自動生成 + +**E-1. 運用ポリシー(日本語プロジェクトでの折衷案)** + +- **コミットメッセージの構造**: `: <日本語の要約>` という折衷形式を採用 + - 例: `feat: 中国語 G2P に Misaki 互換 IPA 出力を追加` + - 例: `fix: ポルトガル語 EP 方言の非ストレス /e/ 弱化を修正` + - 例: `docs: スウェーデン語 README のクイックスタートを更新` +- `type` は英語(conventional commits 標準 `feat`/`fix`/`docs`/`chore`/`refactor`/`perf`/`test`/`build`/`ci`)を使用し、release-please のパーサと互換を取る +- 要約本文は日本語で記述し、CLAUDE.md の「開発言語: 日本語」方針を維持 +- 既存コミット履歴を見ると `feat: スウェーデン語G2P...` `fix: [Preserve]使用パッケージ...` `chore: Bump the all-nuget-dependencies group` の形で既に conventional commits 相当になっているため、運用の追加負荷は低い + +**E-2. release-please の導入手順** + +- `.github/workflows/release-please.yml` を新設し、`googleapis/release-please-action@v4` を利用 +- release type を `simple`(CHANGELOG + tag 管理のみ)または `dotnet` にする + - `dotnet` タイプは `Directory.Build.props` の `` 要素を自動更新するが、本プロジェクトのように 10 csproj が共通 props を参照する構造と相性が良い +- `release-please-config.json` で以下を設定: + - `packages`: モノレポ構造として 10 パッケージを列挙 + - `include-component-in-tag`: `false`(単一バージョン運用) + - `changelog-sections`: 日本語の見出しにマッピング(`feat` → 新機能、`fix` → バグ修正、`docs` → ドキュメント、`perf` → パフォーマンス改善、等) +- 生成される `CHANGELOG.md` は「## v1.10.0 - 2026-04-XX」形式 + 各コミットの日本語本文が列挙される形になる + +**E-3. 現行 release.yml との統合** + +- 現在の `release.yml` は tag push トリガーで NuGet publish を行っている想定 +- release-please は `main` ブランチへの commit を監視し、次期バージョンの PR を自動作成する(`chore(main): release v1.10.0` という PR) +- この PR をマージすると tag と GitHub Release が自動作成 → 既存の `release.yml` が発火 → NuGet publish という流れになり、既存ワークフローを破壊しない +- UPM パッケージのバージョン(`com.dotnetg2p.*`)も release-please の `extra-files` 設定で同期可能 + +**E-4. CI でのドキュメントビルド・リンクチェック** + +- `.github/workflows/ci.yml` に `docs` ジョブを追加: + - `dotnet build -c Release` で DocFX metadata ソースの DLL をビルド + - `docfx docs/docfx.json --warningsAsErrors` で DocFX サイトを警告ゼロでビルド + - `lychee --no-progress --exclude-mail README.md README_EN.md README_ZH.md docs/**/*.md` でリンク切れチェック(`lychee-action@v1` を使用) + - `markdownlint-cli2` で Markdown フォーマット検証 + - `tools/GenerateReadmeSnippets` 実行後の `git diff --exit-code README.md` で SSoT 生成差分のコミット漏れを検出 +- PR プレビュー: `actions/upload-pages-artifact` + `actions/deploy-pages` で PR プレビュー用 GitHub Pages にデプロイし、PR コメントに URL を自動投稿 + +#### F. 実装優先度と段階的ロードマップ + +| フェーズ | 実装項目 | 推定工数 | 期待効果 | +|---------|---------|---------|---------| +| Phase 1(即時) | CI ドキュメントビルド + リンクチェック(E-4 の後半)、release-please 導入(E-2) | 1〜2 日 | リンク切れゼロ、CHANGELOG 自動化 | +| Phase 2(短期) | XMLDoc 拡充 + API テーブル自動生成(B-2)、進捗テーブル自動生成(B-3) | 3〜5 日 | API リファレンス手動更新の撤廃 | +| Phase 3(中期) | テストからのサンプル抽出(D-1 + D-2)、snippet include 方式(B-1) | 5〜7 日 | 出力例と実装の乖離を根本解決 | +| Phase 4(長期) | 翻訳自動化パイプライン(C-2)、多言語 README 統合管理(6.3 の具体化) | 7〜10 日 | 英語・中国語版の即時同期 | + +T05 の本来スコープは Misaki 互換機能のドキュメント整備に限定されているため、本追加レビューで提示した改善案は別途独立したドキュメント戦略チケット(仮称 T07)として切り出すことを推奨する。特に Phase 1 と Phase 2 は既存の CI 資産と整合性が高く、他の機能開発ブランチへの影響が少ないため、先行着手の価値が大きい。 + +### プロジェクト全体振り返りの追加レビュー + +本節は T05 の最終段階で、Mi1〜Mi3 を通じた全体計画を「もし白紙から引き直すなら」の視点で振り返るものである。ドキュメント/QA 観点からの総括であり、T06 §プロジェクト全体振り返りの追加レビュー(リリース観点)と対になっている。Phase 1 (T01/T02)・Phase 2 (T03/T04) で既に浮上した改善案(`record` + `switch` 式、TSV データ駆動テスト、`ChineseOutputFormat` enum による戦略パターン、`IMisakiCapableProcessor` による Multilingual 統合、命名揺れ `ToPiperIPA` vs `ToPiperIpa` の整理、Unity IL2CPP `[Preserve]` 静的検証テスト等)を統合し、ドキュメント・QA の観点で次回に持ち越すべき教訓を明示する。 + +#### A. フェーズ分割の代替案(ドキュメント観点) + +現行は **Mi1(T01/T02)→ Mi2(T03/T04)→ Mi3(T05/T06)** の 3 フェーズ / 6 チケット構成だが、実装規模(ChineseG2PEngine への 4 メソッド追加+変換クラス 1 個+マッピング 18 エントリ差分+テスト 1 ファイル)に比してフェーズ境界が多すぎ、**ドキュメントを「最後にまとめて書く」フェーズに追いやった結果、T03 のシグネチャ確定と README 文面のドリフトが発生しやすい構造**になっている。 + +- **代替案 A-1: 2 フェーズ統合(Mi1+Mi2 = 実装+ドキュメント、Mi3 = リリース判断のみ)** + - Mi3 を「リリース判断」だけに切り詰め、ドキュメント更新は Mi1+Mi2 内部で「コードと同一 PR」の運用にする。これにより T05 §1.1 で挙げた「README の出力例と実装出力の一致保証」が自動化される(実装と同じコミットで書くため)。 +- **代替案 A-2: ドキュメント先行フェーズの追加** + - Mi0(スパイク+ドキュメント骨子) / Mi1(実装) / Mi2(テスト+リリース) の 3 フェーズ。Misaki 本家仕様のドキュメント化と `docs/guides/misaki-compatible-chinese.md` の確定を実装前に完了する。T06 §6.1 の「Misaki 仕様書との差分検証を T04 まで後ろ倒しにした反省」と整合する。 +- **推奨**: Mi3 規模の小さい拡張には **A-1(2 フェーズ)** を推奨。他言語モジュールが採用した 4 フェーズ構成(S1〜S4, F1〜F4, P1〜P4, Sw1〜Sw4)はゼロからの大規模実装用フォーマットであり、既存エンジンへの差分追加には過剰。 + +#### B. チケット粒度の再設計 + +T01〜T06 のドキュメント行数を合計すると約 5,000 行(T01: 1,023 / T02: 968 / T03: 802 / T04: 1,337 / T05: 445 / T06: 約 560)に達しており、**実装 1 行あたりのチケット行数が他言語マイルストーン比で 5〜10 倍**に膨張した。要因の大部分は各チケット末尾の「一から作り直すとしたら」+「追加レビュー」サブセクションで横断論点(record+switch 式、戦略パターン、TSV 駆動、Multilingual 統合)を**フェーズごとに繰り返し議論**したためである。 + +- **T01+T02 統合(= PinyinToMisaki の完成)**: T01 の record+switch 式推奨と T02 の同方式推奨はほぼ同一内容。統合して 1 枚で管理した方が重複が消える。 +- **T03+T04 統合(= エンジン API + テスト同時確定)**: T04 §B の TSV データ駆動テスト、T04 §A の Multilingual プレースホルダ、T04 §B-1 の `[Preserve]` 検証テスト、T04 §C の KokoroSharp 契約テストは、いずれも T03 の API 設計と同時に決めるべき項目。直列化した結果、T04 のレビューが 1,337 行に肥大化した。 +- **T05+T06 統合(= ドキュメント+リリース+Issue 対応)**: 実作業工数は合算で 0.5〜1 人日。T05 で README/CLAUDE.md を更新し T06 でバージョンだけ上げる工程分離は過剰。統合版では「バージョン更新+ドキュメント+リリース実行+Issue コメント投稿」を 1 PR で完結させる。 +- **再設計後のチケット構成案(3 枚)**: + 1. **TA: マッピング+変換クラス+エンジン API 実装**(旧 T01+T02+T03) — ADR 決定事項を前提にリファレンス実装のみ + 2. **TB: テスト+ドキュメント**(旧 T04+T05) — TSV データ駆動テスト+README/CLAUDE.md 同時更新 + 3. **TC: リリース+Issue フォロー**(旧 T06) — バージョンバンプ+CI 実行+Issue コメント +- 6 → 3 枚でチケット枚数が半減し、各チケットごとに 3 エージェントレビューを実施する現行運用の工数も半減する。 + +#### C. 依存関係の並列化機会(T05 視点) + +現行は `T01→T02→T03→T04→T05→T06` の完全直列だが、並列化可能な作業は以下の通り: + +- **T04 テストは T03 API 完了前に書き始められる**: T04 §B-1 の `[Preserve]` 属性存在確認テスト、T04 §B 記載の TSV ローダ実装、TSV の入力カラム(漢字+ピンイン)は T03 のシグネチャ確定を待たずに準備できる。期待値カラムは空のまま PR を先に用意し、T03 確定後に埋める。 +- **T05 ドキュメントは T04 完了を待たなくて良い**: README への「Misaki 互換出力」説明文・CLAUDE.md 進捗表更新・API リファレンス行追加は、T03 のシグネチャ決定後すぐ書ける。T04 完了待ちの理由は「出力例が実装出力と一致する」確認のみで、これは最後に実行すれば良い。 +- **T05 パフォーマンステストは T04 と並行**: テストハーネス(Stopwatch ラッパー、測定対象リスト)は T04 と独立に書ける。 +- **T06 バージョン更新は T05 と並行**: T06 §2.2 の「11 ファイルのバージョン更新」は T05 のドキュメント更新と完全独立。同一 PR にまとめるメリットの方が大きい。 +- **再構成後の依存グラフ案**: + - `TA(実装) ← TB(テスト+ドキュメント 入力設計先行) → TC(リリース)` + - `TA → TB` は実装確定後に合流、TB 内部で TSV 入力設計とドキュメント骨子を並行 +- 理論上の最短日数は現行(直列)より 30〜40% 短縮可能。 + +#### D. Phase 1/2 レビューで共通して浮上した課題の統合(ドキュメント観点) + +Phase 1/Phase 2 の「一から作り直すとしたら」+「追加レビュー」サブセクションを横断して同じ論点が繰り返されている。本来は Mi1 冒頭でプロジェクト全体の設計決定事項(ADR: Architecture Decision Record)として一度記録すべきだった。 + +| 横断論点 | T01/T02 での提示 | T03/T04 での再提示 | ドキュメント上の統合方針 | +|---------|----------------|-------------------|---------| +| `record + switch` 式への移行 | T01 §6.5.2 方式 3 / T02 §6.7.3 方式 3 で**最有力**判定 | T03 では触れず | `docs/adr/0001-pinyin-mapping-strategy.md` に「短期は独立テーブル維持、中期で `PinyinMappingTable` record 化」と記録 | +| 戦略パターン / `ChineseOutputFormat` enum | なし | T03 §B で Phase 1〜4 の段階計画 | `docs/adr/0002-chinese-output-format-enum.md` で v1.10.0 → v2.0.0 の移行ロードマップを確定 | +| TSV データ駆動テスト | なし | T04 §B で Korean/Portuguese/Spanish のパターン流用推奨 | `docs/adr/0003-tsv-driven-test.md` で全言語の `tests/TestData/{Lang}G2P/` 配置規則を標準化 | +| Multilingual への Misaki 能力追加 | なし | T03 §A / T04 §A で `IMisakiCapableProcessor` を提案、T04 はプレースホルダ Skip のみ | Mi 完了条件の必須項目に**「Multilingual 経由のテストが通る」**を昇格 | +| 命名揺れ `ToPiperIPA` vs `ToPiperIpa` | なし | T03 §E で既存バグ発見、`ToPiperIpa` (PascalCase) に寄せる方針 | `docs/adr/0004-api-naming-convention.md` に「3 文字以上の頭字語は PascalCase」を明記 | +| Unity IL2CPP `[Preserve]` 検証テスト | なし | T04 §B-1 で静的解析テスト追加を推奨 | 全言語共通テストクラス `{Lang}G2PEnginePreserveAttributeTests` を共通テンプレ化 | +| Unicode 定数クラス | T01 §推奨 2 で具体コード提示 | なし | 全言語パッケージの `Internal/{Lang}Unicode.cs` を規約化 | +| エントリ順序統一+差分メタテスト | T01 §推奨 1/3 で提示 | T04 で個別言及なし | TB(テスト統合)で `MisakiDiffersFromIpa_OnlyAtKnownPositions` 類を必須化 | + +**ADR 化の提案**: 次回マイルストーン開始時、実装着手前に `docs/adr/{番号}-{タイトル}.md` を 1 枚作成し、上記の「横断論点の決定事項」を全て先取りしておく。これにより各チケットの「一から作り直すとしたら」セクションは「本チケット固有の振り返り」のみに集中でき、ドキュメント総量が 5,000 行から 2,000 行程度に削減できる見込み。なお ADR 形式は既存の `docs/guides/misaki-compatible-chinese.md`(設計ドキュメント)と補完関係にあり、前者が「決定事項のみ簡潔に」、後者が「背景を含む詳細設計」を担う。 + +#### E. 次回マイルストーンプロジェクト(英語/日本語 Misaki 等)への教訓 — ドキュメント/QA 観点 + +T06 §7.3 で提案されている Mi4〜Mi6(英語/日本語/韓国語 の Misaki 互換)に対し、ドキュメント/QA 観点で持ち越すべき教訓: + +1. **ADR 先行**: 上記 D の横断論点を予め ADR として確定させ、個別チケットで再議論しない。 +2. **ドキュメント先行書式の統一**: README.md / CLAUDE.md / `docs/guides/*-milestones.md` へのパッチを**コードと同じ PR 内**で書く(Mi3 で後追いドキュメント更新になった反省点)。`*.md` パッチ用のテンプレート(「### 特徴 の箇所に 1 行追加」「API リファレンス表に N 行追加」等)を `tools/templates/misaki-docs-patch.md` として用意する。 +3. **TSV ゴールデンデータセットの既定化**: 「Misaki Python 実装との差分比較」が本プロジェクトの品質指標の要。TA 段階で TSV (`misaki_golden.tsv`) を 100 ケース以上準備し、Phase 1 終了時点で PER を測定可能にする。スペイン語(PER 1.69%)、ポルトガル語(異音 7 規則)の評価ツール `tools/DotNetG2P.{Lang}Eval` をテンプレ流用。 +4. **Multilingual ファサード統合の必須化**: Mi3 の最大の反省点は「Multilingual 統合を T04 で見送ったまま Mi3 が完了しそう」だった点。次回は「Multilingual 統合テスト通過」を Mi の完了条件(非ゴールではなく MUST ゴール)に昇格させる。 +5. **Kokoro 連携サンプルの同梱**: Mi3 で「動作確認手順の提示のみ」にとどまった反省を踏まえ、`samples/DotNetG2P.Kokoro/` を Mi4 で新設し、Misaki 出力 → KokoroSharp 入力の実動作を手動検証可能にする。 +6. **パフォーマンステストの CI 統合**: T05 §4.3 でローカル実行にとどまった反省を踏まえ、Mi4 では `[Trait("Category", "Performance")]` で CI 分離しつつ、日次スケジュール実行で性能退行を検知する。 +7. **XML ドキュメンテーションコメントの網羅**: Misaki 互換 API には `` に「KokoroSharp 等の Kokoro TTS C# 実装にそのまま入力可能」の 1 行を必須化(T03 §C 推奨事項)。IntelliSense でのディスカバリが主要な利用経路。 + +#### F. T05 / ドキュメント/QA 観点の結論 + +- **フェーズ分割**: 次回は 2 フェーズ(実装+ドキュメント一体 / リリース判断)に圧縮。 +- **チケット粒度**: 6 枚 → 3 枚(TA/TB/TC)に再設計。 +- **並列化**: T04 テスト TSV 入力先行、T05 ドキュメント先行で 30〜40% リードタイム短縮。 +- **ADR 導入**: 横断論点を Mi 開始前に確定し、チケット末尾レビューの重複を排除。 +- **ドキュメント/コード同時更新**: README/CLAUDE.md パッチを実装 PR 内に必須化し、事後ドリフトを防止。 +- **次回持越し**: ADR・ドキュメント・TSV・Multilingual・Kokoro サンプル・性能 CI の 6 項目をテンプレ化。 + +本節の判断は T06 §プロジェクト全体振り返りの追加レビュー(リリース観点)と合わせて一つの全体総括を構成する。双方を参照してから次期マイルストーンを起票すること。 + --- ## 7. 後続タスクへの連絡事項 diff --git a/docs/tickets/T06-release-followup.md b/docs/tickets/T06-release-followup.md index 825f881..b12eb9b 100644 --- a/docs/tickets/T06-release-followup.md +++ b/docs/tickets/T06-release-followup.md @@ -491,6 +491,519 @@ Mi1〜Mi3 全体、さらにプロジェクト全体のリリース管理方式 3. 破壊的変更のない新機能は `-preview.N` を経由(代替案 B) 4. 個別パッケージ独立バージョニングへ段階移行(代替案 D、長期計画) +### 6.4 リリース戦略の追加レビュー + +§6.1〜§6.3 の議論はいずれも妥当だが、抽象度が高く「次にどのファイルを触れば動くか」まで +落ちていない。v1.10.0 でリリース管理の近代化に着手する前提で、6 つの観点について +具体的な実装レベルのレビューと推奨事項を記録する。 + +#### 6.4.1 現在の記載内容の評価 + +| 観点 | 既存記載 | 評価 | 残課題 | +|------|---------|------|-------| +| バージョン自動化 | §6.2 代替案 E で Release Please 言及のみ | 方向性は妥当だが設定例がゼロ | `release-please-config.json` / `.release-please-manifest.json` の具体例が必要 | +| NuGet/UPM 同期 | §5.2 で懸念指摘 + 手動 grep 推奨 | 検出のみで予防策なし | PR 時に CI で自動 fail させる仕組みが必要 | +| プレビュー版 | §6.2 代替案 B で記述 | workflow_dispatch で手動起動前提に留まる | Nightly 自動発行フィードと feed 分離の設計が欠落 | +| 破壊的変更検出 | §5.3 で `EnablePackageValidation=true` のみ | baseline 未設定のため実際は検査が緩い | PublicApiAnalyzer + `PublicAPI.Shipped.txt` 導入が必要 | +| 独立バージョニング | §6.2 代替案 D で概念のみ | 移行コスト試算なし | 「どのファイルを何行触るか」が未提示 | +| Misaki 上流追従 | §6.2 代替案 C で概念のみ | hash 監視の対象ファイル未指定 | 実装可能な YAML サンプルと判定ロジックが必要 | + +結論: 方針は正しいが「Infra-1〜Infra-5 チケット(§7.4)が着手されても手が止まる」粒度。 +本節でその隙間を埋める。 + +#### 6.4.2 release-please による自動バージョンバンプ + CHANGELOG + +**採用根拠**: 本プロジェクトのコミットメッセージは既に日本語 Conventional Commits 風 +(`feat:`, `fix:`, `docs:`, `chore:`)。release-please は日本語本文でも type プレフィクスさえ +あれば SemVer 判定が動くため、導入ハードルはワークフロー 1 本 + 設定 2 ファイルのみ。 + +`.github/workflows/release-please.yml`(新規作成): + +```yaml +name: Release Please +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + id: release + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + token: ${{ secrets.GITHUB_TOKEN }} + # リリース PR がマージされてタグが打たれたら、既存 release.yml を呼び出す + - name: Trigger release.yml + if: ${{ steps.release.outputs.release_created == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: > + gh workflow run release.yml + --ref main + -f version=${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }} +``` + +`release-please-config.json`(ルート、manifest モード・全パッケージ同一バージョン運用時): + +```json +{ + "release-type": "simple", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "include-component-in-tag": false, + "packages": { + ".": { + "package-name": "dot-net-g2p", + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { + "type": "xml", + "path": "Directory.Build.props", + "xpath": "//Project/PropertyGroup/Version" + }, + "src/DotNetG2P.Core/package.json", + "src/DotNetG2P.MeCab/package.json", + "src/DotNetG2P.Chinese/package.json", + "src/DotNetG2P.English/package.json", + "src/DotNetG2P.Korean/package.json", + "src/DotNetG2P.Spanish/package.json", + "src/DotNetG2P.French/package.json", + "src/DotNetG2P.Portuguese/package.json", + "src/DotNetG2P.Swedish/package.json", + "src/DotNetG2P.Multilingual/package.json" + ] + } + }, + "changelog-sections": [ + {"type": "feat", "section": "新機能"}, + {"type": "fix", "section": "バグ修正"}, + {"type": "perf", "section": "パフォーマンス"}, + {"type": "refactor", "section": "リファクタリング"}, + {"type": "docs", "section": "ドキュメント"}, + {"type": "chore", "section": "その他", "hidden": true} + ] +} +``` + +`.release-please-manifest.json`(初期状態): + +```json +{ + ".": "1.9.0" +} +``` + +注意点: +- `Directory.Build.props` は `` が 2 行ある(CI 用 + local 用)。 + xpath 単体では 1 行目しかマッチしないため、`type: "generic"` + 正規表現版の差し替えや、 + tools/bump-version.ps1 を release-please の extra-files hook から呼び出す方式も候補。 + 実装時に hook ベースに倒すのが安全。 +- release-please は Conventional Commits の英語 keyword (`feat`, `fix`, `BREAKING CHANGE:`) + を見るため、本プロジェクトの「日本語本文 + 英語 type」運用は維持する必要がある。 + PR マージ時のスカッシュコミット整形ルールを `CLAUDE.md` に明記しておく。 + +#### 6.4.3 PublicApiAnalyzer による破壊的変更の自動検出 + +現状の `EnablePackageValidation=true` はベースラインパッケージを指定しない限り前バージョン +との diff を取らない(CI ログで「No baseline package was specified」警告が出ているはず)。 +より堅牢な検出には Roslyn の `Microsoft.CodeAnalysis.PublicApiAnalyzers` が向いている。 + +`Directory.Build.props` への追記例: + +```xml + + + + + + + + $(WarningsAsErrors);RS0016;RS0017;RS0022;RS0036;RS0037;RS0041 + +``` + +運用: +1. `PublicAPI.Shipped.txt` を各 `src/DotNetG2P.*/` ディレクトリに作成(v1.9.0 時点の公開 + メンバー一覧を `dotnet format analyzers --diagnostics RS0016` で初回生成) +2. 新 API を追加するとアナライザが RS0016 を出す → 修正 PR で `PublicAPI.Unshipped.txt` に + 追記することでビルドを通す +3. リリース PR では `Unshipped → Shipped` にマージ(release-please の `extra-files` で自動化可能) +4. 既存メンバーを削除するには `PublicAPI.Shipped.txt` から削除 → `BREAKING CHANGE` 扱い + +これにより「気付かずに `public` を `internal` に変更した」「enum 値を削除した」等が +コンパイルエラーとして出る。NuGet Package Validation と併用することで diff の粒度が +「バイナリ互換」と「ソース互換」の両面から担保される。 + +Chinese/Multilingual 以外のパッケージに先行導入して様子を見る案(Es/Fr/Pt は API が +安定している)も現実的。 + +#### 6.4.4 Nightly build + preview feed(GitHub Packages)の設計 + +release.yml は `workflow_dispatch` のみで手動起動前提。これを残しつつ、別ワークフロー +`nightly.yml` を追加して GitHub Packages(NuGet.org ではない)へ日次公開する。 + +`.github/workflows/nightly.yml`(新規作成、例): + +```yaml +name: Nightly Preview +on: + schedule: + - cron: '17 19 * * *' # JST 04:17 毎日 + workflow_dispatch: + +permissions: + contents: read + packages: write # GitHub Packages へ push するために必須 + +env: + DOTNET_VERSION: '9.0.x' + +jobs: + nightly: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # git describe でコミット数を取るため + + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + source-url: https://nuget.pkg.github.com/ayutaz/index.json + env: + NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute preview version + id: ver + run: | + BASE=$(grep -oP '(?<=)[0-9.]+' Directory.Build.props | head -1) + SHA=$(git rev-parse --short HEAD) + STAMP=$(date -u +%Y%m%d%H%M) + VERSION="${BASE}-nightly.${STAMP}+${SHA}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - run: dotnet restore DotNetG2P.slnx + - run: dotnet build DotNetG2P.slnx -c Release -p:Version="${{ steps.ver.outputs.version }}" + - uses: ./.github/actions/setup-dictionary + - run: dotnet test DotNetG2P.slnx -c Release --no-build --filter "Category!=Performance" + + - name: Pack + run: | + for proj in src/DotNetG2P.*/DotNetG2P.*.csproj; do + dotnet pack "$proj" -c Release --no-build \ + -p:PackageVersion="${{ steps.ver.outputs.version }}" \ + --output ./artifacts + done + + - name: Push to GitHub Packages + run: | + for pkg in ./artifacts/*.nupkg; do + dotnet nuget push "$pkg" \ + --api-key ${{ secrets.GITHUB_TOKEN }} \ + --source https://nuget.pkg.github.com/ayutaz/index.json \ + --skip-duplicate + done +``` + +ユーザ側(`nuget.config`): + +```xml + + + + + + +``` + +設計上のポイント: +- **バージョン形式** `1.10.0-nightly.202604150417+abc1234` は SemVer 2.0 準拠。`+` 以降は + build metadata でソート対象外 → NuGet.org 側の通常リリース `1.10.0` と衝突しない +- **GitHub Packages を選ぶ理由**: (1) Pat 不要で `GITHUB_TOKEN` が使える、(2) NuGet.org の + 一度 push すると削除不可ポリシーを回避、(3) 自動 retention を効かせやすい +- **Preview は release-please の prerelease ブランチと併用**: `next` ブランチを作り、 + release-please の `release-as` オプションで `1.10.0-preview.N` を発行する運用にすれば、 + Issue #56 質問者向けに安定 preview を提供できる(nightly より品質が上のチャネル) +- **3 層チャネル構成**: + 1. Nightly (GitHub Packages, 自動, コミットごと) — 開発者向け + 2. Preview (NuGet.org, `-preview.N`, リリース PR 手動マージ) — 早期採用者向け + 3. Stable (NuGet.org, `1.10.0`) — 一般ユーザ向け + +#### 6.4.5 Misaki 上流監視ワークフロー + +§6.2 代替案 C を具体化する。Misaki 本体は現在 `hexgrad/misaki` リポジトリに分離されて +いる(旧 `hexgrad/kokoro` の `misaki/` サブパッケージから独立)ので、監視対象もそこに絞る。 + +`.github/workflows/misaki-upstream-watch.yml`(新規作成、例): + +```yaml +name: Misaki Upstream Watch +on: + schedule: + - cron: '0 21 * * 1' # 毎週月曜 JST 06:00 + workflow_dispatch: + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + watch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Fetch upstream files + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p .misaki-snapshot + # 監視対象: 中国語変換ロジック + 日英韓も将来のため取得 + for file in misaki/zh.py misaki/en.py misaki/ja.py misaki/ko.py misaki/__init__.py pyproject.toml; do + gh api "repos/hexgrad/misaki/contents/${file}" \ + --jq '.content' 2>/dev/null \ + | base64 -d \ + > ".misaki-snapshot/${file//\//_}" || echo "skipped: ${file}" + done + + - name: Compute hash manifest + id: hash + run: | + (cd .misaki-snapshot && sha256sum * | sort) > misaki-hashes.new + if [ -f tools/misaki-watch/misaki-hashes.txt ]; then + if diff -u tools/misaki-watch/misaki-hashes.txt misaki-hashes.new > hash.diff; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "diff<> "$GITHUB_OUTPUT" + cat hash.diff >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + fi + else + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "diff=INITIAL BASELINE" >> "$GITHUB_OUTPUT" + fi + mkdir -p tools/misaki-watch + cp misaki-hashes.new tools/misaki-watch/misaki-hashes.txt + + - name: Get upstream commit + id: upstream + if: steps.hash.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + SHA=$(gh api repos/hexgrad/misaki/commits/main --jq '.sha') + echo "sha=${SHA}" >> "$GITHUB_OUTPUT" + + - name: Open tracking issue + if: steps.hash.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh issue create \ + --title "Misaki upstream 変更検出: ${{ steps.upstream.outputs.sha }}" \ + --label "misaki-upstream,needs-triage" \ + --body "$(cat <` を +共有しているが、以下の移行パスがある。 + +**移行先: Nx/Changesets スタイル(PR 単位で変更パッケージを記録、独立バージョン発行)** + +影響を受けるファイル・コード規模の概算: + +| カテゴリ | 変更内容 | ファイル数 | 工数 | +|---------|---------|-----------|------| +| ビルド設定 | `Directory.Build.props` の `` 削除、各 `.csproj` に `` 追加 | 11 | 0.5d | +| UPM package.json | 各 UPM `package.json` の `version` を `.csproj` と同期するスクリプト | 10 + 1 script | 1d | +| release.yml | `workflow_dispatch` input を `package: all/chinese/multilingual/...` 方式に拡張、マトリクス化 | 1 | 1.5d | +| changeset 管理 | `.changeset/*.md` ディレクトリ運用、Conventional Commits + 変更パッケージ宣言 | 新規 + CLAUDE.md 更新 | 1d | +| 依存関係整合性 | `DotNetG2P.Multilingual` は 8 パッケージに `ProjectReference` 済 → NuGet 公開時は `PackageReference` に切替える Multi-Targeting パターン(現在未対応) | 1 `.csproj` + pack script | 2d | +| リリースノート | パッケージ別 `CHANGELOG.md` 分割 | 10 | 0.5d | +| ドキュメント | README、CLAUDE.md、DocFX の「バージョン表」記述変更 | 3-5 | 0.5d | +| テスト | `MultilingualTests` で参照する Chinese/English 等のバージョン整合性テスト追加 | 1-2 | 0.5d | +| **合計** | | | **約 7.5 人日** | + +**最大の障壁**: `DotNetG2P.Multilingual` が 8 言語パッケージに `ProjectReference` で依存 +している構造。独立バージョニング時は NuGet パッケージとして「固定バージョンの +DotNetG2P.Chinese ≥1.10.0」を参照する形に変わるため、以下のいずれかが必要: + +1. **固定下限参照**: `` + - CI では `ProjectReference`、pack 時に自動変換する MSBuild ターゲット記述が必要 +2. **ソリューションローカル参照のまま Multilingual を常に最新バンプ**: + 既存運用に近い妥協案。Multilingual だけ「依存先が変わったら必ず上がる」ルール +3. **Multilingual をメタパッケージ化**: コードゼロ、依存関係宣言だけのパッケージに分解 + すれば依存バージョン更新のみで release-please が patch bump を自動発行できる + +推奨: 段階移行として **フェーズ 1「Shipped.txt + release-please 導入(単一バージョン維持)」 +→ フェーズ 2「Multilingual をメタパッケージ化」→ フェーズ 3「独立バージョニング」** +の 3 段階で進める。フェーズ 1 だけで「何を変えたか」が CHANGELOG で可視化されるため、 +多くのユーザ課題は解消される。フェーズ 3 は需要が顕在化してから着手で十分。 + +#### 6.4.7 Infra チケットの更新提案 + +§7.4 の Infra-1〜Infra-5 を以下に置き換え推奨: + +| ID | 内容 | 工数見積 | 優先度 | +|---|------|---------|--------| +| Infra-1 | release-please 導入(§6.4.2、manifest + workflow + hook スクリプト) | 2d | 高 | +| Infra-2 | PublicApiAnalyzer + `PublicAPI.Shipped.txt` baseline 生成(§6.4.3) | 1.5d | 高 | +| Infra-3 | Misaki upstream watcher(§6.4.5、hexgrad/misaki 対応) | 1d | 中 | +| Infra-4 | Nightly / preview feed on GitHub Packages(§6.4.4) | 2d | 中 | +| Infra-5 | Multilingual メタパッケージ化検証(§6.4.6 フェーズ 2) | 3d | 低 | +| Infra-6 | Kokoro 連携サンプルプロジェクト(旧 Infra-4) | 2d | 低 | +| Infra-7 | 独立バージョニング完全移行(§6.4.6 フェーズ 3) | 5d | 保留 | + +**v1.10.0 リリース直後の着手推奨順**: Infra-1 → Infra-2 → Infra-3 → Infra-4 → Infra-5。 +Infra-7 は少なくとも 2 件以上の外部要望が来るまで保留で問題ない。 + +### プロジェクト全体振り返りの追加レビュー + +本節は T06(リリース観点)の最終段階で、Mi1〜Mi3 を通じた全体計画を「もし白紙から引き直すなら」の視点で振り返るものである。リリースマネジメント / 配布チャネル観点からの総括であり、T05 §プロジェクト全体振り返りの追加レビュー(ドキュメント/QA 観点)と対になっている。Phase 1 (T01/T02)・Phase 2 (T03/T04) で既に浮上した改善案(`record` + `switch` 式、TSV データ駆動テスト、`ChineseOutputFormat` enum による戦略パターン、`IMisakiCapableProcessor` による Multilingual 統合、命名揺れ `ToPiperIPA` vs `ToPiperIpa` の整理、Unity IL2CPP `[Preserve]` 検証等)を統合し、リリース観点で次回に持ち越すべき教訓を明示する。 + +#### A. フェーズ分割の代替案(リリース観点) + +現行は **Mi1(T01/T02)→ Mi2(T03/T04)→ Mi3(T05/T06)** の 3 フェーズ / 6 チケット構成だが、リリース観点から評価すると**「実装 → テスト → リリース」の古典的ウォーターフォール**であり、NuGet/UPM の継続デリバリー戦略との相性が悪い。具体的には: + +- 全 10 パッケージを同時バージョンアップする現行運用(§6.2 / §6.4)と、フェーズ境界で「リリースしない」現行計画が矛盾しない。Mi1 完了時点で `1.10.0-preview.1` を出せば、Mi2/Mi3 を「本番 feedback を受けながら並行実装」できた。 +- **代替案 A-1: プレビュー版リリースを挟む 2 段階フェーズ** + - **Phase α(Mi1a)**: 最小実装 + `v1.10.0-preview.1` リリース → Issue #56 質問者へ先行提供(§6.2 代替案 B と整合) + - **Phase β(Mi1b〜Mi3)**: フィードバック反映 + 本番リリース + - リリース 2 段階にすることで、「質問者の動作確認」を Mi3 完了後ではなく Mi1 完了直後に前倒しでき、Mi3 での手戻りリスクが解消される。 +- **代替案 A-2: Mi3 をリリース判断のみに切り詰め** + - Mi1/Mi2 内でドキュメント・テスト・バージョン更新まで全て完了させ、Mi3 は「CI 緑確認+`workflow_dispatch` 起動+Issue コメント投稿」のみ。**半日で終わる軽量フェーズ**に再定義する。 + - T05 + T06 の統合に相当。 +- **推奨**: **A-1(プレビュー版挟み)+ A-2(Mi3 軽量化)のハイブリッド**。Mi3 規模の拡張には 4 フェーズも 3 フェーズも過剰で、実質は「実装 → preview → 本番」の 3 段階で完結する。 + +#### B. チケット粒度の再設計(リリース観点) + +T01〜T06 は 6 枚構成だが、**リリース作業専用チケット(T06)が単独で存在**する運用は Mi3 規模には過剰。他言語マイルストーン(S1〜S4, F1〜F4, P1〜P4, Sw1〜Sw4)では各フェーズが「実装+テスト+ドキュメント+リリース」を統合して扱っており、リリース専用チケットは存在しない。T06 の 560 行のドキュメント(§6.4 の追加レビュー 420 行を含む)は、**リリース運用の汎用的な改善案を全て T06 に詰め込んだ結果**であり、本来は別チケット(Infra-1〜Infra-7)として切り出すべきだった。 + +- **T05+T06 統合(= ドキュメント+リリース+Issue 対応)**: §6.4.5 で既に「T05+T06 統合の是非」が触れられているが、Mi3 規模では明確に統合が望ましい。実作業工数は合算で 0.5〜1 人日。1 PR に集約することで: + - PR レビューが 1 回で済む + - バージョン更新(`Directory.Build.props` + 10 package.json)とドキュメント更新(README/CLAUDE.md)のアトミック性が保証される + - `depends_on: [T05]` の直列依存が消える +- **リリース汎用改善案の外出し**: T06 §6.2〜§6.4 の代替案 A〜E(Feature Flag / Preview / Upstream 監視 / 独立バージョニング / Release Please)は**すべて独立チケット(Infra-1〜Infra-7)**として既に §7.4 / §6.4.7 に起票されている。T06 本体はこれらの要約のみに留め、詳細は Infra チケット側に委譲すれば 560 行 → 150 行程度に削減可能。 +- **再設計後のチケット構成案(3 枚)**: + 1. **TA: マッピング+変換クラス+エンジン API 実装**(旧 T01+T02+T03) + 2. **TB: テスト+ドキュメント**(旧 T04+T05) + 3. **TC: リリース+Issue フォロー**(旧 T06 軽量版 = バージョン更新+ワークフロー起動+Issue コメント投稿の 3 手順のみ) +- リリース観点で特筆すべきは、**TC が実質的に `release.yml` の `workflow_dispatch` を叩くだけのチケット**となる点。Release Please(§6.4.6 Infra-1)導入後はさらに PR 作成自体が自動化され、TC は「自動生成された PR のマージ+動作確認」のみで完了する。 + +#### C. 依存関係の並列化機会(リリース観点) + +現行は `T01→T02→T03→T04→T05→T06` の完全直列で、リリースが Mi3 末尾に押し付けられている。リリース観点での並列化機会: + +- **T06 バージョン更新は T05 と独立**: §2.2 の「11 ファイルのバージョン更新」は T05 のドキュメント更新と完全独立。同一 PR にまとめるメリット大。 +- **T06 リリースノート案は T03 完了時点で書き始められる**: §2.4 のリリースノート本文(新機能・改善・後方互換性)は API シグネチャ確定(T03)後すぐ執筆可能。T05 のパフォーマンステスト結果だけ最後に挿入すれば良い。 +- **T06 Issue コメント文面は T03 完了時点で書き始められる**: §2.3 の Issue #56 コメント文面は、使用例コードが書ける T03 完了時点でドラフト可能。実際の投稿だけ T06 で行う。 +- **T06 破壊的変更チェックは T04 完了時点で実行可能**: §5.3 の `git diff v1.9.0..HEAD -- 'src/DotNetG2P.Chinese/**/*.cs' | grep -E '^-\s+public '` チェックは、T04 完了直後に CI で自動実行できる。T06 まで待つ理由がない。 +- **preview 版の先行リリース**: §6.4 代替案 B の `v1.10.0-preview.1` は T03 完了直後に出せる。Mi2/Mi3 の作業と**並行して外部 feedback を集められる**。 +- 理論上の最短日数は現行(直列)より 30〜40% 短縮可能。特に「Issue 質問者への動作確認提供」リードタイムを Mi3 完了 → Mi1 完了直後に前倒しできる意義は大きい。 + +#### D. Phase 1/2 レビューで共通して浮上した課題の統合(リリース観点) + +Phase 1/Phase 2 の横断論点とリリース時の対応関係を整理: + +| 横断論点 | T01〜T04 での提示 | T05/T06 での統合先 | リリース時の対応 | +|---------|-----------------|-----------------|---------| +| `record + switch` 式への移行 | T01/T02 で**最有力**判定 | §6.4 で触れず | v1.11.0(中期)で別 Minor リリース | +| 戦略パターン / `ChineseOutputFormat` enum | T03 §B で Phase 1〜4 の段階計画 | §5.1 破壊的変更チェックリストに反映 | Phase 1 (v1.10.0): enum 追加 / Phase 3 (v1.12.0): `[Obsolete]` / Phase 4 (v2.0.0): 削除 | +| TSV データ駆動テスト | T04 §B で Korean/Portuguese/Spanish パターン流用 | §5.4 辞書ファイルサイズチェック | TSV 追加時は NuGet パッケージサイズ影響を確認(現状の 250MB 上限には余裕) | +| Multilingual への Misaki 能力追加 | T03 §A / T04 §A で `IMisakiCapableProcessor` 提案 | §4.2 Multilingual テスト確認項目 | Mi3 完了条件の MUST ゴールに昇格(現行は非ゴール扱い) | +| 命名揺れ `ToPiperIPA` vs `ToPiperIpa` | T03 §E で発見、PascalCase に寄せる方針 | §5.3 破壊的変更チェック | v2.0.0 で `ToPiperIPA` を削除、`ToPiperIpa` を正規名に確定 | +| Unity IL2CPP `[Preserve]` 検証 | T04 §B-1 で静的解析テスト推奨 | §4.2 `unity-meta-check` ジョブで補強 | `link.xml` 不要、クラスレベル `[Preserve]` で十分 | +| Unicode 定数クラス | T01 §推奨 2 で具体コード | §4.2 テストカウント変動で間接検証 | TA(統合後)の最初のコミットで `Internal/ChineseUnicode.cs` を追加 | +| Upstream 監視自動化 | なし | §6.4 代替案 C(Infra-3) | Mi4 開始前に `schedule` ワークフローで Misaki リポジトリ hash 監視 | + +**リリース観点の ADR 提案**: 次回マイルストーン開始時、`docs/adr/0005-release-strategy-for-misaki-compatible.md` を作成し: + +- 初回リリースは `-preview.N` を必ず挟む +- `Directory.Build.props` の共通 `` は維持(§6.4.6 で独立バージョニングへ移行予定だが当面維持) +- Release Please 導入後は PR 作成を自動化 +- 破壊的変更チェックリストは CI で自動実行 +- Multilingual 統合テスト通過を Mi 完了条件の MUST ゴールに昇格 + +を明記することで、Mi4〜Mi6 で再議論せずに済む。 + +#### E. 次回マイルストーンプロジェクト(英語/日本語 Misaki 等)への教訓 — リリース観点 + +T06 §7.3 で提案されている Mi4〜Mi6(英語/日本語/韓国語 Misaki 互換)に対し、リリース観点で持ち越すべき教訓: + +1. **Release Please 先行導入**: Infra-1(§6.4.6 推奨)を Mi4 開始前に完了させる。Mi4 以降のバージョン更新 PR が自動化され、T06 相当のチケット工数がゼロになる。 +2. **プレビュー版の常態化**: 全 Mi で `-preview.N → 安定版` の 2 段階リリースを標準化(§6.4 代替案 B)。外部要望ドリブン機能では **Phase 1 完了直後に preview を出す** ことで feedback を前倒し。 +3. **独立バージョニングの部分導入**: §6.4.6 フェーズ 2「Multilingual メタパッケージ化」を Mi4 前に検証。Misaki 拡張は常に 2 パッケージ(`DotNetG2P.{Lang}` + `DotNetG2P.Multilingual`)に影響するため、変更のないパッケージはバージョン据え置きにしたい。 +4. **Upstream 監視の自動化**: Infra-3(§6.4 代替案 C)を Mi4 開始前に稼働させ、Misaki Python 実装の仕様変更を週次で検知。手動追従では毎回 T01〜T06 相当の工数が発生する。 +5. **Multilingual 統合の必須化**: Mi3 の最大の反省点は「`IMisakiCapableProcessor` 追加を T06 §7.1 送りにしてしまった」点。次回は**「Multilingual 経由のテストが通ること」を Mi 完了条件の MUST ゴール**に昇格する(§4.2 の Multilingual テスト項目を拡張)。 +6. **Kokoro 連携サンプルの同梱**: §7 記載の Infra-6(旧 Infra-4)を Mi4 リリース直前に完了。Misaki 出力が実際に Kokoro で動くことをリリース前に保証する。 +7. **SBOM / 破壊的変更チェックの自動化強化**: 現行の `dotnet pack -p:EnablePackageValidation=true` で破壊的変更検出は自動化済み(§5.3)。これに加えて CycloneDX SBOM の diff を GitHub Release 本文に自動添付することを Mi4 で検討。 +8. **バージョン番号の意味論の明文化**: §5.1 のセマンティックバージョニング運用を全 Mi で徹底。`Feature flag` 方式(§6.2 代替案 A)を採用すれば Mi4〜Mi6 の全言語 Misaki 対応を**単一の Minor バージョン(v1.11.0)で済ませる**選択肢も取れる(既存ユーザーへの影響ゼロ)。 +9. **リリース後 smoke の必須化**: §4.2「リリース後スモークテスト」で示されている「クリーン環境での `dotnet add package` + 実行」を Mi4 以降は必須フェーズ化。ロールバック戦略(§5.6)の発動を未然に防ぐ。 + +#### F. T06 / リリース観点の結論 + +- **フェーズ分割**: 次回は「実装 → preview → 本番」の 3 段階、Mi3 軽量化で実質 2 フェーズ運用。 +- **チケット粒度**: 6 枚 → 3 枚(TA/TB/TC)に再設計。TC は Release Please 導入後は自動生成 PR のマージのみで完了。 +- **並列化**: preview 版先行で Issue feedback を前倒し、破壊的変更チェックを T04 直後に CI 実行、リリースノート/Issue コメント文面を T03 完了時点から執筆開始。 +- **ADR 導入**: リリース戦略を `docs/adr/0005-release-strategy-for-misaki-compatible.md` に確定させ、Mi4〜Mi6 で再議論しない。 +- **Infra チケット先行**: Mi4 開始前に Infra-1(Release Please)/ Infra-3(Upstream 監視)/ Infra-4(破壊的変更自動化)を完了させる。 +- **Multilingual 統合**: 次回 Mi の完了条件 MUST ゴールに昇格。 +- **次回持越し**: Release Please、Preview 版運用、Upstream 監視、Kokoro サンプル、独立バージョニング(段階導入)、SBOM diff 自動添付の 6 項目をテンプレ化。 + +本節の判断は T05 §プロジェクト全体振り返りの追加レビュー(ドキュメント/QA 観点)と合わせて一つの全体総括を構成する。双方を参照してから次期マイルストーン(Mi4: 英語 Misaki 互換等)を起票すること。具体的には本セクション F と T05 §F の結論を統合した「Mi4 起票前チェックリスト」を `docs/adr/` に新設することを推奨する。 + --- ## 7. 後続タスクへの連絡事項 From 6323a1fb3027b6c9b234e71ce533dce178352edf Mon Sep 17 00:00:00 2001 From: yousan Date: Sun, 12 Apr 2026 22:52:28 +0900 Subject: [PATCH 11/21] =?UTF-8?q?feat(chinese):=20T01=20PinyinToMisaki=20?= =?UTF-8?q?=E3=83=9E=E3=83=83=E3=83=94=E3=83=B3=E3=82=B0=E3=83=86=E3=83=BC?= =?UTF-8?q?=E3=83=96=E3=83=AB=E5=AE=9F=E8=A3=85=20(Mi1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Misaki 互換中国語G2Pの第一ステップとして、声母・韻母・声調の全 マッピングテーブルを定義する内部静的クラスを新規追加。 - src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs (新規) - 声母 22 エントリ Dictionary * j/q は合字 ʨ/ʨʰ (U+02A8) を使用 (標準IPA tɕ/tɕʰ との差異) * z/c は合字 ʦ/ʦʰ (U+02A6) を使用 (標準IPA ts/tsʰ との差異) - 韻母 36 エントリ Dictionary * 二重母音 ai/ei/ao/ou → ai̯/ei̯/au̯/ou̯ (非音節化符号 U+032F) * iao/iu/uai/ui も同様に非音節化符号付与 * ong/iong は u̯ŋ/iu̯ŋ の表記 - 声調 5 エントリ string[] (矢印記号 → ↗ ↓ ↘) - そり舌母音 ɻ̩ / 歯茎母音 ɹ̩ は標準IPAと同一 - テスタビリティ用 internal アクセサ 5 種 (GetInitialMisaki / GetFinalMisaki / GetToneArrow / GetRetroflexApical / GetAlveolarApical) - tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiMappingTests.cs (新規) - 声母 22 + 韻母 36 + 声調 5 の全エントリを検証する 127 テスト - Misaki 固有の差異 (ʨ/ʦ 合字、非音節化符号、矢印声調) に focused テストを追加 - 全 Initial/Final 網羅性テスト 全127テスト成功。PinyinToIpa/PinyinToPiperIpa に影響なし。 Ref: T01 (docs/tickets/T01-misaki-mapping-tables.md), Mi1, Issue #56 --- .../Conversion/PinyinToMisaki.cs | 128 +++++++ .../ChineseG2P/PinyinToMisakiMappingTests.cs | 348 ++++++++++++++++++ 2 files changed, 476 insertions(+) create mode 100644 src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs create mode 100644 tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiMappingTests.cs diff --git a/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs b/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs new file mode 100644 index 0000000..239512f --- /dev/null +++ b/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs @@ -0,0 +1,128 @@ +using System.Collections.Generic; + +namespace DotNetG2P.Chinese +{ + /// + /// Misaki 互換のピンイン→IPA変換。 + /// Kokoro TTS の G2P フロントエンド と同一の音素表記を使用する。 + /// 主な差異: + /// + /// 破擦音 j/q/z/c は 1 文字合字 (ʨ/ʨʰ/ʦ/ʦʰ) を使用 + /// 二重母音の滑り音に非音節化符号 (U+032F) を付与 (ai̯, au̯, ei̯, ou̯ …) + /// 声調は IPA tone letters ではなく矢印記号 (→ ↗ ↓ ↘) を使用 + /// + /// + internal static class PinyinToMisaki + { + // 声母→IPAマッピング(Misaki準拠) + private static readonly Dictionary s_initialMisaki = new Dictionary + { + [Initial.B] = "p", + [Initial.P] = "p\u02B0", // pʰ + [Initial.M] = "m", + [Initial.F] = "f", + [Initial.D] = "t", + [Initial.T] = "t\u02B0", // tʰ + [Initial.N] = "n", + [Initial.L] = "l", + [Initial.G] = "k", + [Initial.K] = "k\u02B0", // kʰ + [Initial.H] = "x", + [Initial.J] = "\u02A8", // ʨ (Misaki: 合字、標準IPAは tɕ) + [Initial.Q] = "\u02A8\u02B0", // ʨʰ + [Initial.X] = "\u0255", // ɕ + [Initial.Zh] = "\u0288\u0282", // ʈʂ + [Initial.Ch] = "\u0288\u0282\u02B0", // ʈʂʰ + [Initial.Sh] = "\u0282", // ʂ + [Initial.R] = "\u027B", // ɻ + [Initial.Z] = "\u02A6", // ʦ (Misaki: 合字、標準IPAは ts) + [Initial.C] = "\u02A6\u02B0", // ʦʰ + [Initial.S] = "s", + [Initial.Y] = "j", + [Initial.W] = "w", + }; + + // 韻母→IPAマッピング(Misaki準拠) + // 二重母音の滑り音には非音節化符号 U+032F (COMBINING INVERTED BREVE BELOW) を付与する。 + private static readonly Dictionary s_finalMisaki = new Dictionary + { + [Final.A] = "a", + [Final.O] = "o", + [Final.E] = "\u0264", // ɤ + [Final.Ai] = "ai\u032F", // ai̯ (標準IPA: aɪ) + [Final.Ei] = "ei\u032F", // ei̯ (標準IPA: eɪ) + [Final.Ao] = "au\u032F", // au̯ (標準IPA: aʊ) + [Final.Ou] = "ou\u032F", // ou̯ (標準IPA: oʊ) + [Final.An] = "an", + [Final.En] = "\u0259n", // ən + [Final.Ang] = "a\u014B", // aŋ + [Final.Eng] = "\u0259\u014B", // əŋ + [Final.Ong] = "u\u032F\u014B", // u̯ŋ (標準IPA: ʊŋ) + [Final.I] = "i", + [Final.Ia] = "ia", + [Final.Ie] = "i\u025B", // iɛ + [Final.Iao] = "iau\u032F", // iau̯ + [Final.Iu] = "iou\u032F", // iou̯ + [Final.Ian] = "i\u025Bn", // iɛn + [Final.In] = "in", + [Final.Iang] = "ia\u014B", // iaŋ + [Final.Ing] = "i\u014B", // iŋ + [Final.Iong] = "iu\u032F\u014B", // iu̯ŋ (標準IPA: iʊŋ) + [Final.U] = "u", + [Final.Ua] = "ua", + [Final.Uo] = "uo", + [Final.Uai] = "uai\u032F", // uai̯ + [Final.Ui] = "uei\u032F", // uei̯ + [Final.Uan] = "uan", + [Final.Un] = "u\u0259n", // uən + [Final.Uang] = "ua\u014B", // uaŋ + [Final.Ueng] = "u\u0259\u014B", // uəŋ + [Final.V] = "y", + [Final.Ve] = "y\u025B", // yɛ + [Final.Van] = "yan", + [Final.Vn] = "yn", + [Final.Er] = "\u0259\u027B", // əɻ + }; + + // 声調→矢印記号マッピング(Misaki準拠) + private static readonly string[] s_toneArrows = new string[] + { + "", // Neutral (0) - 軽声は声調なし + "\u2192", // First (1) → 陰平 + "\u2197", // Second (2) ↗ 陽平 + "\u2193", // Third (3) ↓ 上声 + "\u2198", // Fourth (4) ↘ 去声 + }; + + // zh/ch/sh/r + i のそり舌母音 ɻ̩ (PinyinToIpa と同一) + private static readonly string s_retroflexApical = "\u027B\u0329"; // ɻ̩ + + // z/c/s + i の歯茎母音 ɹ̩ (PinyinToIpa と同一) + private static readonly string s_alveolarApical = "\u0279\u0329"; // ɹ̩ + + /// 声母 Initial に対応する Misaki 互換 IPA 文字列を返す(テスト・検証用)。 + internal static string GetInitialMisaki(Initial initial) + { + return s_initialMisaki.TryGetValue(initial, out var value) ? value : string.Empty; + } + + /// 韻母 Final に対応する Misaki 互換 IPA 文字列を返す(テスト・検証用)。 + internal static string GetFinalMisaki(Final final_) + { + return s_finalMisaki.TryGetValue(final_, out var value) ? value : string.Empty; + } + + /// 声調 Tone に対応する Misaki 互換矢印記号を返す(テスト・検証用)。 + internal static string GetToneArrow(Tone tone) + { + int index = (int)tone; + return (index >= 0 && index < s_toneArrows.Length) ? s_toneArrows[index] : string.Empty; + } + + /// zh/ch/sh/r + i で使用するそり舌母音を返す(テスト・検証用)。 + internal static string GetRetroflexApical() => s_retroflexApical; + + /// z/c/s + i で使用する歯茎母音を返す(テスト・検証用)。 + internal static string GetAlveolarApical() => s_alveolarApical; + } +} diff --git a/tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiMappingTests.cs b/tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiMappingTests.cs new file mode 100644 index 0000000..b849a29 --- /dev/null +++ b/tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiMappingTests.cs @@ -0,0 +1,348 @@ +using DotNetG2P.Chinese; +using Xunit; + +namespace DotNetG2P.Tests.ChineseG2P +{ + /// + /// PinyinToMisaki のマッピングテーブル単体テスト。 + /// 声母 22 エントリ + 韻母 36 エントリ + 声調 5 エントリの全マッピングを検証する。 + /// Convert メソッドのロジックテストは T02 の PinyinToMisakiConvertTests 側で行う。 + /// + public class PinyinToMisakiMappingTests + { + // ===== 声母マッピング ===== + + [Theory] + [InlineData(Initial.B, "p")] + [InlineData(Initial.P, "p\u02B0")] // pʰ + [InlineData(Initial.M, "m")] + [InlineData(Initial.F, "f")] + public void InitialMapping_Labials_ReturnsExpectedMisakiIpa(Initial initial, string expected) + { + Assert.Equal(expected, PinyinToMisaki.GetInitialMisaki(initial)); + } + + [Theory] + [InlineData(Initial.D, "t")] + [InlineData(Initial.T, "t\u02B0")] // tʰ + [InlineData(Initial.N, "n")] + [InlineData(Initial.L, "l")] + public void InitialMapping_Alveolars_ReturnsExpectedMisakiIpa(Initial initial, string expected) + { + Assert.Equal(expected, PinyinToMisaki.GetInitialMisaki(initial)); + } + + [Theory] + [InlineData(Initial.G, "k")] + [InlineData(Initial.K, "k\u02B0")] // kʰ + [InlineData(Initial.H, "x")] + public void InitialMapping_Velars_ReturnsExpectedMisakiIpa(Initial initial, string expected) + { + Assert.Equal(expected, PinyinToMisaki.GetInitialMisaki(initial)); + } + + // ── Misaki 差異: j/q は合字 ʨ/ʨʰ (U+02A8) を使用 ── + + [Fact] + public void InitialMapping_J_UsesTcLigature() + { + // DotNetG2P 標準IPA: "t\u0255" (tɕ) + // Misaki: "\u02A8" (ʨ、合字) + Assert.Equal("\u02A8", PinyinToMisaki.GetInitialMisaki(Initial.J)); + } + + [Fact] + public void InitialMapping_Q_UsesTcLigatureWithAspiration() + { + // DotNetG2P 標準IPA: "t\u0255\u02B0" (tɕʰ) + // Misaki: "\u02A8\u02B0" (ʨʰ) + Assert.Equal("\u02A8\u02B0", PinyinToMisaki.GetInitialMisaki(Initial.Q)); + } + + [Fact] + public void InitialMapping_X_UnchangedFromStandardIpa() + { + Assert.Equal("\u0255", PinyinToMisaki.GetInitialMisaki(Initial.X)); + } + + [Theory] + [InlineData(Initial.Zh, "\u0288\u0282")] // ʈʂ + [InlineData(Initial.Ch, "\u0288\u0282\u02B0")] // ʈʂʰ + [InlineData(Initial.Sh, "\u0282")] // ʂ + [InlineData(Initial.R, "\u027B")] // ɻ + public void InitialMapping_Retroflex_ReturnsExpectedMisakiIpa(Initial initial, string expected) + { + Assert.Equal(expected, PinyinToMisaki.GetInitialMisaki(initial)); + } + + // ── Misaki 差異: z/c は合字 ʦ/ʦʰ (U+02A6) を使用 ── + + [Fact] + public void InitialMapping_Z_UsesTsLigature() + { + // DotNetG2P 標準IPA: "ts" + // Misaki: "\u02A6" (ʦ、合字) + Assert.Equal("\u02A6", PinyinToMisaki.GetInitialMisaki(Initial.Z)); + } + + [Fact] + public void InitialMapping_C_UsesTsLigatureWithAspiration() + { + // DotNetG2P 標準IPA: "ts\u02B0" (tsʰ) + // Misaki: "\u02A6\u02B0" (ʦʰ) + Assert.Equal("\u02A6\u02B0", PinyinToMisaki.GetInitialMisaki(Initial.C)); + } + + [Fact] + public void InitialMapping_S_UnchangedFromStandardIpa() + { + Assert.Equal("s", PinyinToMisaki.GetInitialMisaki(Initial.S)); + } + + [Theory] + [InlineData(Initial.Y, "j")] + [InlineData(Initial.W, "w")] + public void InitialMapping_Semivowels_ReturnsExpectedMisakiIpa(Initial initial, string expected) + { + Assert.Equal(expected, PinyinToMisaki.GetInitialMisaki(initial)); + } + + [Fact] + public void InitialMapping_None_ReturnsEmpty() + { + Assert.Equal(string.Empty, PinyinToMisaki.GetInitialMisaki(Initial.None)); + } + + // ===== 韻母マッピング ===== + + [Theory] + [InlineData(Final.A, "a")] + [InlineData(Final.O, "o")] + [InlineData(Final.E, "\u0264")] // ɤ + public void FinalMapping_SimpleVowels_ReturnsExpectedMisakiIpa(Final final_, string expected) + { + Assert.Equal(expected, PinyinToMisaki.GetFinalMisaki(final_)); + } + + // ── Misaki 差異: 二重母音に非音節化符号 U+032F を付与 ── + + [Theory] + [InlineData(Final.Ai, "ai\u032F")] // ai̯ (標準IPA: aɪ) + [InlineData(Final.Ei, "ei\u032F")] // ei̯ (標準IPA: eɪ) + [InlineData(Final.Ao, "au\u032F")] // au̯ (標準IPA: aʊ) + [InlineData(Final.Ou, "ou\u032F")] // ou̯ (標準IPA: oʊ) + public void FinalMapping_OpenDiphthongs_UseNonSyllabicMark(Final final_, string expected) + { + Assert.Equal(expected, PinyinToMisaki.GetFinalMisaki(final_)); + } + + [Theory] + [InlineData(Final.Iao, "iau\u032F")] // iau̯ + [InlineData(Final.Iu, "iou\u032F")] // iou̯ (iu = iou) + [InlineData(Final.Uai, "uai\u032F")] // uai̯ + [InlineData(Final.Ui, "uei\u032F")] // uei̯ (ui = uei) + public void FinalMapping_ComplexDiphthongs_UseNonSyllabicMark(Final final_, string expected) + { + Assert.Equal(expected, PinyinToMisaki.GetFinalMisaki(final_)); + } + + [Fact] + public void FinalMapping_Ong_UsesNonSyllabicUBeforeNg() + { + // 標準IPA: "\u028A\u014B" (ʊŋ) + // Misaki: "u\u032F\u014B" (u̯ŋ) + Assert.Equal("u\u032F\u014B", PinyinToMisaki.GetFinalMisaki(Final.Ong)); + } + + [Fact] + public void FinalMapping_Iong_UsesNonSyllabicUBeforeNg() + { + // 標準IPA: "i\u028A\u014B" (iʊŋ) + // Misaki: "iu\u032F\u014B" (iu̯ŋ) + Assert.Equal("iu\u032F\u014B", PinyinToMisaki.GetFinalMisaki(Final.Iong)); + } + + // ── PinyinToIpa と同一の韻母(差異なし) ── + + [Theory] + [InlineData(Final.An, "an")] + [InlineData(Final.En, "\u0259n")] // ən + [InlineData(Final.Ang, "a\u014B")] // aŋ + [InlineData(Final.Eng, "\u0259\u014B")] // əŋ + public void FinalMapping_OpenNasals_UnchangedFromStandardIpa(Final final_, string expected) + { + Assert.Equal(expected, PinyinToMisaki.GetFinalMisaki(final_)); + } + + [Theory] + [InlineData(Final.I, "i")] + [InlineData(Final.Ia, "ia")] + [InlineData(Final.Ie, "i\u025B")] // iɛ + [InlineData(Final.Ian, "i\u025Bn")] // iɛn + [InlineData(Final.In, "in")] + [InlineData(Final.Iang, "ia\u014B")] // iaŋ + [InlineData(Final.Ing, "i\u014B")] // iŋ + public void FinalMapping_FrontVowelFinals_UnchangedFromStandardIpa(Final final_, string expected) + { + Assert.Equal(expected, PinyinToMisaki.GetFinalMisaki(final_)); + } + + [Theory] + [InlineData(Final.U, "u")] + [InlineData(Final.Ua, "ua")] + [InlineData(Final.Uo, "uo")] + [InlineData(Final.Uan, "uan")] + [InlineData(Final.Un, "u\u0259n")] // uən + [InlineData(Final.Uang, "ua\u014B")] // uaŋ + [InlineData(Final.Ueng, "u\u0259\u014B")] // uəŋ + public void FinalMapping_BackVowelFinals_UnchangedFromStandardIpa(Final final_, string expected) + { + Assert.Equal(expected, PinyinToMisaki.GetFinalMisaki(final_)); + } + + [Theory] + [InlineData(Final.V, "y")] + [InlineData(Final.Ve, "y\u025B")] // yɛ + [InlineData(Final.Van, "yan")] + [InlineData(Final.Vn, "yn")] + public void FinalMapping_CloseFrontRoundedFinals_UnchangedFromStandardIpa(Final final_, string expected) + { + Assert.Equal(expected, PinyinToMisaki.GetFinalMisaki(final_)); + } + + [Fact] + public void FinalMapping_Er_UnchangedFromStandardIpa() + { + Assert.Equal("\u0259\u027B", PinyinToMisaki.GetFinalMisaki(Final.Er)); + } + + [Fact] + public void FinalMapping_None_ReturnsEmpty() + { + Assert.Equal(string.Empty, PinyinToMisaki.GetFinalMisaki(Final.None)); + } + + // ===== 特殊母音(そり舌・歯茎) ===== + + [Fact] + public void RetroflexApical_MatchesStandardIpa() + { + // zh/ch/sh/r + i のそり舌母音 ɻ̩ (U+027B + U+0329) + Assert.Equal("\u027B\u0329", PinyinToMisaki.GetRetroflexApical()); + } + + [Fact] + public void AlveolarApical_MatchesStandardIpa() + { + // z/c/s + i の歯茎母音 ɹ̩ (U+0279 + U+0329) + Assert.Equal("\u0279\u0329", PinyinToMisaki.GetAlveolarApical()); + } + + // ===== 声調マッピング ===== + + [Fact] + public void ToneMapping_Neutral_ReturnsEmpty() + { + Assert.Equal(string.Empty, PinyinToMisaki.GetToneArrow(Tone.Neutral)); + } + + [Fact] + public void ToneMapping_First_ReturnsRightArrow() + { + Assert.Equal("\u2192", PinyinToMisaki.GetToneArrow(Tone.First)); // → + } + + [Fact] + public void ToneMapping_Second_ReturnsNorthEastArrow() + { + Assert.Equal("\u2197", PinyinToMisaki.GetToneArrow(Tone.Second)); // ↗ + } + + [Fact] + public void ToneMapping_Third_ReturnsDownArrow() + { + Assert.Equal("\u2193", PinyinToMisaki.GetToneArrow(Tone.Third)); // ↓ + } + + [Fact] + public void ToneMapping_Fourth_ReturnsSouthEastArrow() + { + Assert.Equal("\u2198", PinyinToMisaki.GetToneArrow(Tone.Fourth)); // ↘ + } + + // ===== 網羅性検証 ===== + + [Theory] + [InlineData(Initial.B)] + [InlineData(Initial.P)] + [InlineData(Initial.M)] + [InlineData(Initial.F)] + [InlineData(Initial.D)] + [InlineData(Initial.T)] + [InlineData(Initial.N)] + [InlineData(Initial.L)] + [InlineData(Initial.G)] + [InlineData(Initial.K)] + [InlineData(Initial.H)] + [InlineData(Initial.J)] + [InlineData(Initial.Q)] + [InlineData(Initial.X)] + [InlineData(Initial.Zh)] + [InlineData(Initial.Ch)] + [InlineData(Initial.Sh)] + [InlineData(Initial.R)] + [InlineData(Initial.Z)] + [InlineData(Initial.C)] + [InlineData(Initial.S)] + [InlineData(Initial.Y)] + [InlineData(Initial.W)] + public void InitialMapping_HasEntryForAllNonNoneInitials(Initial initial) + { + // None 以外の全 Initial がマッピングテーブルに存在すること + Assert.NotEqual(string.Empty, PinyinToMisaki.GetInitialMisaki(initial)); + } + + [Theory] + [InlineData(Final.A)] + [InlineData(Final.O)] + [InlineData(Final.E)] + [InlineData(Final.Ai)] + [InlineData(Final.Ei)] + [InlineData(Final.Ao)] + [InlineData(Final.Ou)] + [InlineData(Final.An)] + [InlineData(Final.En)] + [InlineData(Final.Ang)] + [InlineData(Final.Eng)] + [InlineData(Final.Ong)] + [InlineData(Final.I)] + [InlineData(Final.Ia)] + [InlineData(Final.Ie)] + [InlineData(Final.Iao)] + [InlineData(Final.Iu)] + [InlineData(Final.Ian)] + [InlineData(Final.In)] + [InlineData(Final.Iang)] + [InlineData(Final.Ing)] + [InlineData(Final.Iong)] + [InlineData(Final.U)] + [InlineData(Final.Ua)] + [InlineData(Final.Uo)] + [InlineData(Final.Uai)] + [InlineData(Final.Ui)] + [InlineData(Final.Uan)] + [InlineData(Final.Un)] + [InlineData(Final.Uang)] + [InlineData(Final.Ueng)] + [InlineData(Final.V)] + [InlineData(Final.Ve)] + [InlineData(Final.Van)] + [InlineData(Final.Vn)] + [InlineData(Final.Er)] + public void FinalMapping_HasEntryForAllNonNoneFinals(Final final_) + { + // None 以外の全 Final がマッピングテーブルに存在すること + Assert.NotEqual(string.Empty, PinyinToMisaki.GetFinalMisaki(final_)); + } + } +} From 3f9880cb5281f7604016018bd898e5fe6418e9bf Mon Sep 17 00:00:00 2001 From: yousan Date: Sun, 12 Apr 2026 23:06:52 +0900 Subject: [PATCH 12/21] =?UTF-8?q?feat(chinese):=20T02=20PinyinToMisaki=20C?= =?UTF-8?q?onvert=20=E3=83=A1=E3=82=BD=E3=83=83=E3=83=89=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=20(Mi1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T01 で定義したマッピングテーブルを使い、Convert/ConvertSyllable の 公開/内部メソッドを追加。PinyinToIpa.ConvertSyllable の変換ロジック (半母音省略・そり舌母音・歯茎母音・声調付与)を踏襲しつつ、 テーブル参照のみを Misaki 用に差し替えた。 - src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs - public Convert(string) / Convert(string, bool includeTones) - internal ConvertSyllable(PinyinSyllable, bool) - private IsRetroflex / IsAlveolar / ShouldOmitSemivowel - 処理フロー: null/空チェック → ToneConverter.ToToneMarked → PinyinParser.TryParse → ConvertSyllable - ConvertSyllable ステップ: 声母 (Y/W 半母音省略判定) → 韻母 (そり舌/歯茎母音の特別処理) → 声調矢印 (includeTones && !Neutral のときのみ) - tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiConvertTests.cs (新規) - 61 件のテスト: * 4声 + 軽声の矢印変換 * 数字声調形式 (ma1-ma4) の正規化 * includeTones=false の声調省略 * j/q/z/c の Misaki 合字 (ʨ/ʨʰ/ʦ/ʦʰ) * 二重母音の非音節化符号 (ai̯/au̯/ei̯/ou̯/iau̯/iou̯/uai̯/uei̯) * ong/iong の u̯ŋ/iu̯ŋ 表記 * そり舌母音 (zhi/chi/shi/ri) * 歯茎母音 (zi/ci/si) * y/w 半母音省略 (yi/yin/ying/yu/yue/yuan/yun/wu) * ゼロ声母 (a/e/o) * null/空/不正入力のエッジケース * PinyinToIpa との差分確認 (j 声母・ai 韻母・声調マーカー) - Issue #56 参照例 (nǐ/hǎo) の個別音節変換 テスト結果: - PinyinToMisakiConvertTests: 61/61 成功 - PinyinToMisakiMappingTests: 127/127 成功 (T01 分) - Chinese 全体 (perf除く): 1497/1497 成功、回帰なし Ref: T02 (docs/tickets/T02-misaki-convert-method.md), Mi1, Issue #56 --- .../Conversion/PinyinToMisaki.cs | 137 +++++++++ .../ChineseG2P/PinyinToMisakiConvertTests.cs | 283 ++++++++++++++++++ 2 files changed, 420 insertions(+) create mode 100644 tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiConvertTests.cs diff --git a/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs b/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs index 239512f..7a95510 100644 --- a/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs +++ b/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Text; namespace DotNetG2P.Chinese { @@ -124,5 +125,141 @@ internal static string GetToneArrow(Tone tone) /// z/c/s + i で使用する歯茎母音を返す(テスト・検証用)。 internal static string GetAlveolarApical() => s_alveolarApical; + + /// + /// 声調記号付きピンインを Misaki 互換 IPA 表記に変換する(声調矢印付き)。 + /// + /// 声調記号付きまたは声調数字付きのピンイン文字列。 + /// Misaki 互換 IPA 表記文字列。パース失敗時は空文字列。 + public static string Convert(string pinyin) + { + return Convert(pinyin, true); + } + + /// + /// 声調記号付きピンインを Misaki 互換 IPA 表記に変換する。 + /// + /// 声調記号付きまたは声調数字付きのピンイン文字列。 + /// 声調矢印を含めるかどうか。 + /// Misaki 互換 IPA 表記文字列。パース失敗時は空文字列。 + public static string Convert(string pinyin, bool includeTones) + { + if (string.IsNullOrEmpty(pinyin)) + return string.Empty; + + // 数字声調形式("ma1"等)を声調記号付きに変換してからパース + string normalized = ToneConverter.ToToneMarked(pinyin); + if (!PinyinParser.TryParse(normalized, out var syllable)) + return string.Empty; + + return ConvertSyllable(syllable, includeTones); + } + + /// + /// PinyinSyllable を Misaki 互換 IPA 表記に変換する。 + /// + internal static string ConvertSyllable(PinyinSyllable syllable, bool includeTones) + { + var sb = new StringBuilder(16); + + // 声母の Misaki IPA + if (syllable.Initial != Initial.None) + { + if (syllable.Initial == Initial.Y || syllable.Initial == Initial.W) + { + // y/w は韻母側が対応する母音で始まる場合、半母音を省略する + if (!ShouldOmitSemivowel(syllable.Initial, syllable.Final)) + { + sb.Append(s_initialMisaki[syllable.Initial]); + } + } + else + { + sb.Append(s_initialMisaki[syllable.Initial]); + } + } + + // 韻母の Misaki IPA + if (syllable.Final != Final.None) + { + // zhi/chi/shi/ri の i はそり舌母音 ɻ̩ + if (syllable.Final == Final.I && IsRetroflex(syllable.Initial)) + { + sb.Append(s_retroflexApical); + } + // zi/ci/si の i は歯茎母音 ɹ̩ + else if (syllable.Final == Final.I && IsAlveolar(syllable.Initial)) + { + sb.Append(s_alveolarApical); + } + else + { + sb.Append(s_finalMisaki[syllable.Final]); + } + } + + // 声調矢印 + if (includeTones && syllable.Tone != Tone.Neutral) + { + sb.Append(s_toneArrows[(int)syllable.Tone]); + } + + return sb.ToString(); + } + + /// zh/ch/sh/r のそり舌声母かどうか。 + private static bool IsRetroflex(Initial initial) + { + return initial == Initial.Zh + || initial == Initial.Ch + || initial == Initial.Sh + || initial == Initial.R; + } + + /// z/c/s の歯茎声母かどうか。 + private static bool IsAlveolar(Initial initial) + { + return initial == Initial.Z + || initial == Initial.C + || initial == Initial.S; + } + + /// y/w の半母音を省略すべきかどうかを判定する。 + private static bool ShouldOmitSemivowel(Initial initial, Final final_) + { + if (initial == Initial.Y) + { + // y + i系韻母: 韻母が i で始まるので j は不要 + // y + ü系韻母: 韻母が y[IPA] で始まるので j は不要 + switch (final_) + { + case Final.I: + case Final.In: + case Final.Ing: + case Final.V: + case Final.Ve: + case Final.Van: + case Final.Vn: + return true; + default: + return false; + } + } + + if (initial == Initial.W) + { + // w + u系韻母: 韻母が u で始まるので w は不要 + switch (final_) + { + case Final.U: + case Final.Un: + return true; + default: + return false; + } + } + + return false; + } } } diff --git a/tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiConvertTests.cs b/tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiConvertTests.cs new file mode 100644 index 0000000..355552f --- /dev/null +++ b/tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiConvertTests.cs @@ -0,0 +1,283 @@ +using DotNetG2P.Chinese; +using Xunit; + +namespace DotNetG2P.Tests.ChineseG2P +{ + /// + /// PinyinToMisaki.Convert / ConvertSyllable の単体テスト。 + /// 声母・韻母・声調・半母音省略・そり舌/歯茎母音など、 + /// PinyinToIpa と同一の変換ロジックが Misaki マッピングでも正しく機能することを検証する。 + /// + public class PinyinToMisakiConvertTests + { + // ===== 声調マーカーのテスト(4声 + 軽声) ===== + + [Theory] + [InlineData("mā", "ma\u2192")] // 1声 → + [InlineData("má", "ma\u2197")] // 2声 ↗ + [InlineData("mǎ", "ma\u2193")] // 3声 ↓ + [InlineData("mà", "ma\u2198")] // 4声 ↘ + public void Convert_AllTones_ReturnsCorrectArrow(string pinyin, string expected) + { + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); + } + + [Theory] + [InlineData("ma", "ma")] // 声調記号なし = 軽声 → 矢印なし + [InlineData("de", "t\u0264")] // 助詞「的」の軽声読み → tɤ (矢印なし) + public void Convert_NeutralTone_OmitsArrow(string pinyin, string expected) + { + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); + } + + [Theory] + [InlineData("ma1", "ma\u2192")] // 数字声調形式 1声 + [InlineData("ma2", "ma\u2197")] // 数字声調形式 2声 + [InlineData("ma3", "ma\u2193")] // 数字声調形式 3声 + [InlineData("ma4", "ma\u2198")] // 数字声調形式 4声 + public void Convert_NumericToneFormat_NormalizedCorrectly(string pinyin, string expected) + { + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); + } + + // ===== includeTones=false で声調を省略 ===== + + [Theory] + [InlineData("mā", "ma")] + [InlineData("nǐ", "ni")] + [InlineData("hǎo", "xau\u032F")] + [InlineData("wū", "u")] // w + u → u (半母音 w 省略) + public void Convert_IncludeTonesFalse_OmitsToneMarker(string pinyin, string expected) + { + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin, includeTones: false)); + } + + // ===== Misaki 固有の声母差異 (j/q/z/c) ===== + + [Fact] + public void Convert_J_UsesTcLigature() + { + // 標準IPA: "tɕi˥˥" → Misaki: "ʨi→" + Assert.Equal("\u02A8i\u2192", PinyinToMisaki.Convert("jī")); + } + + [Fact] + public void Convert_Q_UsesTcLigatureWithAspiration() + { + // 標準IPA: "tɕʰi˥˥" → Misaki: "ʨʰi→" + Assert.Equal("\u02A8\u02B0i\u2192", PinyinToMisaki.Convert("qī")); + } + + [Fact] + public void Convert_Z_UsesTsLigatureBeforeVowel() + { + // 標準IPA: "tsa˥˥" → Misaki: "ʦa→" + Assert.Equal("\u02A6a\u2192", PinyinToMisaki.Convert("zā")); + } + + [Fact] + public void Convert_C_UsesTsLigatureWithAspiration() + { + // 標準IPA: "tsʰa˥˥" → Misaki: "ʦʰa→" + Assert.Equal("\u02A6\u02B0a\u2192", PinyinToMisaki.Convert("cā")); + } + + // ===== Misaki 固有の韻母差異 (二重母音の非音節化符号) ===== + + [Theory] + [InlineData("bái", "pai\u032F\u2197")] // b+ai 2声 → pai̯↗ + [InlineData("mèi", "mei\u032F\u2198")] // m+ei 4声 → mei̯↘ + [InlineData("māo", "mau\u032F\u2192")] // m+ao 1声 → mau̯→ + [InlineData("dòu", "tou\u032F\u2198")] // d+ou 4声 → tou̯↘ + public void Convert_Diphthongs_UseNonSyllabicMark(string pinyin, string expected) + { + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); + } + + [Theory] + [InlineData("miáo", "miau\u032F\u2197")] // m+iao 2声 → miau̯↗ + [InlineData("liù", "liou\u032F\u2198")] // l+iu(iou) 4声 → liou̯↘ + [InlineData("guāi", "kuai\u032F\u2192")] // g+uai 1声 → kuai̯→ + [InlineData("duì", "tuei\u032F\u2198")] // d+ui(uei) 4声 → tuei̯↘ + public void Convert_ComplexDiphthongs_UseNonSyllabicMark(string pinyin, string expected) + { + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); + } + + [Fact] + public void Convert_Ong_UsesNonSyllabicUBeforeNg() + { + // t + ong + 1声 → t + u̯ŋ + → = "tu̯ŋ→" + Assert.Equal("tu\u032F\u014B\u2192", PinyinToMisaki.Convert("dōng")); + } + + [Fact] + public void Convert_Iong_UsesNonSyllabicUBeforeNg() + { + // x + iong + 2声 → ɕ + iu̯ŋ + ↗ = "ɕiu̯ŋ↗" + Assert.Equal("\u0255iu\u032F\u014B\u2197", PinyinToMisaki.Convert("xióng")); + } + + // ===== そり舌母音 (zh/ch/sh/r + i) ===== + + [Theory] + [InlineData("zhī", "\u0288\u0282\u027B\u0329\u2192")] // ʈʂɻ̩→ + [InlineData("chī", "\u0288\u0282\u02B0\u027B\u0329\u2192")] // ʈʂʰɻ̩→ + [InlineData("shī", "\u0282\u027B\u0329\u2192")] // ʂɻ̩→ + [InlineData("rì", "\u027B\u027B\u0329\u2198")] // ɻɻ̩↘ + public void Convert_RetroflexPlusI_UsesRetroflexApical(string pinyin, string expected) + { + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); + } + + // ===== 歯茎母音 (z/c/s + i) ===== + + [Theory] + [InlineData("zī", "\u02A6\u0279\u0329\u2192")] // ʦɹ̩→ (Misaki 合字 ʦ) + [InlineData("cī", "\u02A6\u02B0\u0279\u0329\u2192")] // ʦʰɹ̩→ + [InlineData("sī", "s\u0279\u0329\u2192")] // sɹ̩→ + public void Convert_AlveolarPlusI_UsesAlveolarApical(string pinyin, string expected) + { + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); + } + + // ===== 半母音省略 (y/w + 対応韻母) ===== + + [Theory] + [InlineData("yī", "i\u2192")] // y + i → i (半母音省略) + [InlineData("yīn", "in\u2192")] // y + in → in + [InlineData("yīng", "i\u014B\u2192")] // y + ing → iŋ + public void Convert_YPlusIFinals_OmitsSemivowel(string pinyin, string expected) + { + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); + } + + [Theory] + [InlineData("yū", "y\u2192")] // y + ü → y (撮口呼は PinyinParser が Final.V として解釈) + [InlineData("yuē", "y\u025B\u2192")] // y + üe → yɛ + [InlineData("yuán", "yan\u2197")] // y + üan → yan + [InlineData("yūn", "yn\u2192")] // y + ün → yn + public void Convert_YPlusUFinals_OmitsSemivowel(string pinyin, string expected) + { + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); + } + + [Theory] + [InlineData("wū", "u\u2192")] // w + u → u (半母音 w 省略) + [InlineData("wù", "u\u2198")] // w + u 4声 → u↘ + public void Convert_WPlusUFinals_OmitsSemivowel(string pinyin, string expected) + { + // PinyinParser は "wen" を Initial.W + Final.En としてパースするため、 + // 半母音省略は "wu" 系列のみが対象となる。 + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); + } + + [Theory] + [InlineData("yā", "ja\u2192")] // y + a → ja (省略しない) + [InlineData("wǒ", "wo\u2193")] // w + o → wo (PinyinParser: W + O) + [InlineData("wài", "wai\u032F\u2198")] // w + ai → wai̯ (PinyinParser: W + Ai) + [InlineData("wēn", "w\u0259n\u2192")] // w + en → wən (PinyinParser: W + En) + public void Convert_YWPlusOtherFinals_KeepsSemivowel(string pinyin, string expected) + { + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); + } + + // ===== Issue #56 由来の参照例 ===== + + [Fact] + public void Convert_NiHao_ReturnsMisakiCompatibleOutput() + { + // PinyinToMisaki は個別音節のみ変換する(声調変調なし) + // nǐ (3声) → ni↓ + // hǎo (3声) → xau̯↓ + Assert.Equal("ni\u2193", PinyinToMisaki.Convert("nǐ")); + Assert.Equal("xau\u032F\u2193", PinyinToMisaki.Convert("hǎo")); + } + + // ===== エッジケース ===== + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Convert_NullOrEmpty_ReturnsEmpty(string pinyin) + { + Assert.Equal(string.Empty, PinyinToMisaki.Convert(pinyin)); + } + + [Theory] + [InlineData("xyz")] // 不正ピンイン + [InlineData("123")] // 数字のみ + [InlineData("!!!")] // 記号のみ + public void Convert_InvalidPinyin_ReturnsEmpty(string pinyin) + { + Assert.Equal(string.Empty, PinyinToMisaki.Convert(pinyin)); + } + + // ===== ConvertSyllable 直接テスト ===== + + [Fact] + public void ConvertSyllable_BasicCase_ReturnsExpected() + { + var syllable = new PinyinSyllable(Initial.M, Final.A, Tone.First); + Assert.Equal("ma\u2192", PinyinToMisaki.ConvertSyllable(syllable, includeTones: true)); + } + + [Fact] + public void ConvertSyllable_WithoutTone_OmitsToneArrow() + { + var syllable = new PinyinSyllable(Initial.N, Final.I, Tone.Third); + Assert.Equal("ni", PinyinToMisaki.ConvertSyllable(syllable, includeTones: false)); + } + + [Fact] + public void ConvertSyllable_NeutralTone_NoArrowRegardlessOfFlag() + { + var syllable = new PinyinSyllable(Initial.M, Final.A, Tone.Neutral); + Assert.Equal("ma", PinyinToMisaki.ConvertSyllable(syllable, includeTones: true)); + Assert.Equal("ma", PinyinToMisaki.ConvertSyllable(syllable, includeTones: false)); + } + + [Fact] + public void ConvertSyllable_ZeroInitial_OnlyFinal() + { + // ゼロ声母 (a, e, o など): 韻母のみ + var syllable = new PinyinSyllable(Initial.None, Final.A, Tone.First); + Assert.Equal("a\u2192", PinyinToMisaki.ConvertSyllable(syllable, includeTones: true)); + } + + // ===== PinyinToIpa との差分が期待通りに現れることの確認 ===== + + [Fact] + public void Convert_DiffersFromStandardIpa_AtJInitial() + { + string misaki = PinyinToMisaki.Convert("jī", includeTones: false); + string standardIpa = PinyinToIpa.Convert("jī", includeTones: false); + // Misaki: "\u02A8i" (ʨi), 標準IPA: "t\u0255i" (tɕi) + Assert.NotEqual(standardIpa, misaki); + Assert.Equal("\u02A8i", misaki); + Assert.Equal("t\u0255i", standardIpa); + } + + [Fact] + public void Convert_DiffersFromStandardIpa_AtAiFinal() + { + string misaki = PinyinToMisaki.Convert("bái", includeTones: false); + string standardIpa = PinyinToIpa.Convert("bái", includeTones: false); + // Misaki: "pai̯" (pai + U+032F), 標準IPA: "paɪ" (pa + U+026A) + Assert.NotEqual(standardIpa, misaki); + Assert.Equal("pai\u032F", misaki); + Assert.Equal("pa\u026A", standardIpa); + } + + [Fact] + public void Convert_DiffersFromStandardIpa_AtToneMarker() + { + string misaki = PinyinToMisaki.Convert("mā", includeTones: true); + string standardIpa = PinyinToIpa.Convert("mā", includeTones: true); + // Misaki: "ma→" (U+2192), 標準IPA: "ma˥˥" (U+02E5 U+02E5) + Assert.NotEqual(standardIpa, misaki); + Assert.Equal("ma\u2192", misaki); + Assert.Equal("ma\u02E5\u02E5", standardIpa); + } + } +} From fdab28ebc64b6d39796f37613c859adf356ef2f1 Mon Sep 17 00:00:00 2001 From: yousan Date: Mon, 13 Apr 2026 00:36:47 +0900 Subject: [PATCH 13/21] =?UTF-8?q?feat(chinese):=20Phase=201-R=20=E2=80=94?= =?UTF-8?q?=20PinyinToMisaki=20=E3=82=92=20Misaki=20=E5=85=AC=E5=BC=8F?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=20(0.9.4)=20=E6=BA=96=E6=8B=A0=E3=81=AB?= =?UTF-8?q?=E5=85=A8=E9=9D=A2=E6=9B=B8=E3=81=8D=E6=8F=9B=E3=81=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 レビューエージェントが hexgrad/misaki 公式ソースを取得・実測した結果、 旧実装 (推定仕様ベース) と Misaki 実出力に多数の乖離を発見。 uv misaki 0.9.4 で 137 件の gold standard を実測し、全面書き換え。 ### 声母 (21 エントリ) - j/q → ʨ/ʨʰ (U+02A8、合字) — 旧仕様と同一 (correct) - z/c → ʦ/ʦʰ (U+02A6) — 旧仕様と同一 (correct) - zh/ch → ꭧ/ꭧʰ (U+AB67) ← **旧 ʈʂ/ʈʂʰ から変更** (Kokoro 82M vocab に U+AB67 含有を確認) - Y/W は声母テーブルから除外 (compound final 層で処理) ### 韻母 (36 エントリ、Prefix + Tone + Suffix 方式) - 声調位置: **末尾付与→韻母内挿入** (例: "an"→"a{tone}n") - U+032F: テンプレートから事前除去 (legacy_call strip 相当) - ɻ̩/ɹ̩ → **ɨ (U+0268) 直接** (retone 相当) - ong/iong: **ʊŋ/jʊŋ** (旧 u̯ŋ/iu̯ŋ は誤り) - i 系: **j 半母音付与** (ia→ja, ian→jɛn, ie→je 等) - u 系: **w 半母音付与** (ua→wa, uo→wo, uan→wan 等) - ü 系: üe→**ɥe** (U+0265)、üan→**ɥɛn** (旧 yɛ/yan は誤り) - Er: **ɚ** (U+025A、旧 əɻ は誤り) - O: **wo** (bpmf+o)、**ɔ** (単独感嘆詞) ### Y/W 複合韻母 (23 エントリ、新規テーブル) - DotNetG2P PinyinParser は "wang" を W+Ang にパースするため、 Misaki の "uang" 相当に変換する (Initial,Final)→(Prefix,Suffix,OmitInitial) lookup - 省略ケース: yi→i, yin→in, ying→iŋ, yu→y, yun→yn, wu→u ### テスト - PinyinToMisakiMappingTests: 127→172 件 (+45) - PinyinToMisakiConvertTests: 61→150 件 (+89) - Chinese 全体 (perf除く): 1631 件成功、回帰なし - 全期待値は uv misaki 0.9.4 の実測 gold standard に基づく ### チケット - T01/T02 を Misaki 公式準拠で全面書き換え (Phase 1-R 教訓を追加) 検証済み: - Kokoro 82M vocab: ꭧ/ʨ/ʦ/ɨ/ɥ/ʊ/ə/ɤ/ɛ/ɚ/ɔ/→/↗/↓/↘ 全て含有 - Y/W 23 組合せ: Misaki と全件一致 (独立実測) - gold standard 137+770=907 件: エラーゼロ Ref: T01, T02, Mi1, Issue #56 --- docs/tickets/T01-misaki-mapping-tables.md | 1368 +++++++-------- docs/tickets/T02-misaki-convert-method.md | 1557 +++++++++-------- .../Conversion/PinyinToMisaki.cs | 320 ++-- .../ChineseG2P/PinyinToMisakiConvertTests.cs | 522 ++++-- .../ChineseG2P/PinyinToMisakiMappingTests.cs | 700 ++++++-- 5 files changed, 2603 insertions(+), 1864 deletions(-) diff --git a/docs/tickets/T01-misaki-mapping-tables.md b/docs/tickets/T01-misaki-mapping-tables.md index 93a42bd..c8f3a3e 100644 --- a/docs/tickets/T01-misaki-mapping-tables.md +++ b/docs/tickets/T01-misaki-mapping-tables.md @@ -13,15 +13,34 @@ blocks: [T02] ### 背景 -Kokoro TTS の G2P フロントエンド Misaki は、中国語音素表記に独自の IPA バリアント(破擦音の合字記号使用、二重母音の非音節化符号、矢印型声調記号)を採用している。DotNetG2P.Chinese は現在 3 種類の出力形式(標準 IPA、piper-plus 互換 IPA、注音符号)を提供しているが、Misaki 互換形式には未対応であり、Kokoro TTS ユーザーが DotNetG2P を G2P フロントエンドとして利用できない状況にある(Issue #56)。 +Kokoro TTS の G2P フロントエンド Misaki は、中国語音素表記に独自の IPA バリアントを採用している。DotNetG2P.Chinese は現在 3 種類の出力形式(標準 IPA、piper-plus 互換 IPA、注音符号)を提供しているが、Misaki 互換形式には未対応であり、Kokoro TTS ユーザーが DotNetG2P を G2P フロントエンドとして利用できない状況にある(Issue #56)。 + +初期の Phase 1(現行仕様)では Misaki の独自表記を一部推測で構成していたが、**Phase 1-R で Misaki 公式実装 (`hexgrad/misaki` の `misaki/zh.py` + `misaki/transcription.py`) を fetch し、`uv run python` で実測した 137 件の gold standard (`.claude/tmp/misaki-gold.txt`) により完全な仕様が verified された**。本チケットはこの verified 仕様に基づきマッピングテーブルを再定義する。 + +### Phase 1-R で verified された主要ポイント + +1. **J/Q は ligature U+02A8 `ʨ`** を使用する(旧仕様の `tɕ` は誤り)。 +2. **Z/C は ligature U+02A6 `ʦ`** を使用する(旧仕様の `ts` 2 文字は誤り)。 +3. **Zh/Ch は Unicode U+AB67 `ꭧ`** を使用する(旧仕様の `ʈʂ` (U+0288 U+0282) は誤り)。 +4. **retroflex/alveolar apical の I は直接 `ɨ` (U+0268)** である(旧仕様の `ɻ̩` / `ɹ̩` は誤り)。 +5. **Ong は `ʊŋ` (U+028A U+014B)** である(旧仕様の `u̯ŋ` (U+0075 U+032F U+014B) は誤り)。 +6. **Ai/Ei/Ao/Ou 等は U+032F 非音節化符号なし** で `ai`/`ei`/`au`/`ou` である(旧仕様は誤り)。 +7. **Ian/Van は `jɛn` / `ɥɛn`** (U+025B) である(旧仕様の `ian`/`yan` は誤り)。 +8. **Iong は `jʊŋ`**、**Ve/Van は `ɥe` / `ɥɛn`** (U+0265) である。 +9. **声調矢印は韻母の Prefix と Suffix の間に挿入する**(韻母末尾ではない)。例: `man1` → `ma→n`(`man→` ではない)。 +10. **Y/W は声母層ではなく複合韻母層** で処理される。Misaki では "wang" は `wa→ŋ` であり、Initial.W の 1 文字マッピングでは対応できない。 +11. **bpmf + o は pwo/pʰwo/mwo/fwo** 形式(`bo1` → `pwo→`)である。単独感嘆詞 `o` は `ɔ` (U+0254) である(bpmf + o の `wo` とは異なる)。 +12. **Er は `ɚ` (U+025A)** 単独である(旧仕様の `əɻ` は誤り)。 ### ゴール -既存の `PinyinToIpa.cs` / `PinyinToPiperIpa.cs` / `PinyinToZhuyin.cs` と同じ変換クラスパターンで `PinyinToMisaki.cs` を新規作成するための、**声母・韻母・声調の全マッピングテーブルを確定する**。本チケットのスコープはテーブル定義のみであり、Convert メソッドの統合は後続 T02 で行う。 +既存の `PinyinToIpa.cs` / `PinyinToPiperIpa.cs` / `PinyinToZhuyin.cs` と同じ変換クラスパターンで `PinyinToMisaki.cs` を新規作成するための、**声母・韻母・声調・Y/W 複合韻母の全マッピングテーブルを Misaki 公式準拠で確定する**。本チケットのスコープはテーブル定義のみであり、Convert メソッドの統合は後続 T02 で行う。 ### 達成基準 -- 声母 22 エントリ、韻母 32 エントリ、声調 5 エントリすべてのマッピングが確定し、コードに `Dictionary` / `Dictionary` / `string[]` として実装されていること +- 声母 22 エントリ、韻母 36 エントリ(Prefix + Suffix 方式)、声調 5 エントリ、特殊母音 3 ケース、Y/W 複合韻母 23 エントリすべてのマッピングが確定し、コードに実装されていること +- Phase 1-R gold standard(137 件、`.claude/tmp/misaki-gold.txt`)との照合が通ること +- Kokoro 82M vocab 互換性(Inv6 verified)が確認されていること - PinyinToIpa との差異が明確にドキュメント化されていること - 全マッピングのユニットテストが通過すること @@ -29,156 +48,358 @@ Kokoro TTS の G2P フロントエンド Misaki は、中国語音素表記に ### 2.1 声母テーブル(22 エントリ) -`PinyinToIpa.cs` の `s_initialIpa` を基準とし、Misaki で異なる表記を使用する箇所を太字で示す。 - -| # | Initial enum | ピンイン | PinyinToIpa(標準 IPA) | PinyinToMisaki | Unicode シーケンス | 差異 | -|---|-------------|---------|------------------------|----------------|-------------------|------| -| 1 | `B` | b | p | p | `p` | | -| 2 | `P` | p | ph | ph | `p\u02B0` | | -| 3 | `M` | m | m | m | `m` | | -| 4 | `F` | f | f | f | `f` | | -| 5 | `D` | d | t | t | `t` | | -| 6 | `T` | t | th | th | `t\u02B0` | | -| 7 | `N` | n | n | n | `n` | | -| 8 | `L` | l | l | l | `l` | | -| 9 | `G` | g | k | k | `k` | | -| 10 | `K` | k | kh | kh | `k\u02B0` | | -| 11 | `H` | h | x | x | `x` | | -| 12 | `J` | j | t\u0255 (tc) | **\u02A8** (**cc**) | **`\u02A8`** | **tc -> cc (U+02A8 合字)** | -| 13 | `Q` | q | t\u0255\u02B0 (tch) | **\u02A8\u02B0** (**cch**) | **`\u02A8\u02B0`** | **tch -> cch (U+02A8 合字 + 有気)** | -| 14 | `X` | x | \u0255 (c) | c | `\u0255` | | -| 15 | `Zh` | zh | \u0288\u0282 (ts) | \u0288\u0282 (ts) | `\u0288\u0282` | | -| 16 | `Ch` | ch | \u0288\u0282\u02B0 (tsh) | \u0288\u0282\u02B0 (tsh) | `\u0288\u0282\u02B0` | | -| 17 | `Sh` | sh | \u0282 (s) | \u0282 (s) | `\u0282` | | -| 18 | `R` | r | \u027B (r) | \u027B (r) | `\u027B` | | -| 19 | `Z` | z | ts | **\u02A6** (**ts**) | **`\u02A6`** | **ts 2文字 -> U+02A6 合字** | -| 20 | `C` | c | ts\u02B0 (tsh) | **\u02A6\u02B0** (**tsh**) | **`\u02A6\u02B0`** | **tsh -> U+02A6 合字 + 有気** | -| 21 | `S` | s | s | s | `s` | | -| 22 | `Y` | y | j | j | `j` | | -| 23 | `W` | w | w | w | `w` | | +`PinyinToIpa.cs` の `s_initialIpa` を基準とし、Misaki で異なる表記を使用する箇所を太字で示す。**Y/W は声母層ではなく複合韻母層で処理するため、声母テーブル本体には含めないが enum としては残す**(`ConvertSyllable` 側で Y/W 複合韻母テーブルを優先的に参照する)。 + +Phase 1-R gold standard より検証済み(`uv run python -c "import misaki.zh; ..."` で全 22 エントリを実測): + +| # | Initial enum | ピンイン | PinyinToIpa(標準 IPA) | PinyinToMisaki | Unicode シーケンス | 差異 | gold 例 | +|---|-------------|---------|------------------------|----------------|-------------------|------|--------| +| 1 | `B` | b | p | p | `p` | 同一 | `ba1` → `pa→` | +| 2 | `P` | p | pʰ | pʰ | `p\u02B0` | 同一 | `pa2` → `pʰa↗` | +| 3 | `M` | m | m | m | `m` | 同一 | `ma1` → `ma→` | +| 4 | `F` | f | f | f | `f` | 同一 | `fa3` → `fa↓` | +| 5 | `D` | d | t | t | `t` | 同一 | `da4` → `ta↘` | +| 6 | `T` | t | tʰ | tʰ | `t\u02B0` | 同一 | `ta1` → `tʰa→` | +| 7 | `N` | n | n | n | `n` | 同一 | `na2` → `na↗` | +| 8 | `L` | l | l | l | `l` | 同一 | `la3` → `la↓` | +| 9 | `G` | g | k | k | `k` | 同一 | `ga4` → `ka↘` | +| 10 | `K` | k | kʰ | kʰ | `k\u02B0` | 同一 | `ka1` → `kʰa→` | +| 11 | `H` | h | x | x | `x` | 同一 | `ha2` → `xa↗` | +| 12 | **`J`** | j | **tɕ** (t\u0255) | **ʨ** | **`\u02A8`** | **tɕ → ʨ 合字 (U+02A8)** | `ji1` → **`ʨi→`** | +| 13 | **`Q`** | q | **tɕʰ** (t\u0255\u02B0) | **ʨʰ** | **`\u02A8\u02B0`** | **tɕʰ → ʨʰ (U+02A8 + 有気)** | `qi2` → **`ʨʰi↗`** | +| 14 | `X` | x | ɕ (\u0255) | ɕ | `\u0255` | 同一 | `xi3` → `ɕi↓` | +| 15 | **`Zh`** | zh | **ʈʂ** (\u0288\u0282) | **ꭧ** | **`\uAB67`** | **ʈʂ 2 文字 → U+AB67 合字** | `zhi4` → **`ꭧɨ↘`** | +| 16 | **`Ch`** | ch | **ʈʂʰ** (\u0288\u0282\u02B0) | **ꭧʰ** | **`\uAB67\u02B0`** | **ʈʂʰ → U+AB67 + 有気** | `chi1` → **`ꭧʰɨ→`** | +| 17 | `Sh` | sh | ʂ (\u0282) | ʂ | `\u0282` | 同一 | `shi2` → `ʂɨ↗` | +| 18 | `R` | r | ɻ (\u027B) | ɻ | `\u027B` | 同一 | `ri3` → `ɻɨ↓` | +| 19 | **`Z`** | z | **ts** (2 文字) | **ʦ** | **`\u02A6`** | **ts 2 文字 → U+02A6 合字** | `zi4` → **`ʦɨ↘`** | +| 20 | **`C`** | c | **tsʰ** (ts\u02B0) | **ʦʰ** | **`\u02A6\u02B0`** | **tsʰ → U+02A6 + 有気** | `ci1` → **`ʦʰɨ→`** | +| 21 | `S` | s | s | s | `s` | 同一 | `si2` → `sɨ↗` | +| 22 | **`Y`** | y | j | **—(compound final 層で処理)** | — | **声母マップから除外** | `ya1` → `ja→` | +| 23 | **`W`** | w | w | **—(compound final 層で処理)** | — | **声母マップから除外** | `wa1` → `wa→` | **差異まとめ(声母):** -- **j** (Initial.J): 標準 IPA `tɕ` (U+0074 U+0255) -> Misaki `ʨ` (U+02A8、ラテン小文字 TC ダイグラフ) -- **q** (Initial.Q): 標準 IPA `tɕʰ` (U+0074 U+0255 U+02B0) -> Misaki `ʨʰ` (U+02A8 U+02B0) -- **z** (Initial.Z): 標準 IPA `ts` (U+0074 U+0073) -> Misaki `ʦ` (U+02A6、ラテン小文字 TS ダイグラフ) -- **c** (Initial.C): 標準 IPA `tsʰ` (U+0074 U+0073 U+02B0) -> Misaki `ʦʰ` (U+02A6 U+02B0) - -### 2.2 韻母テーブル(32 エントリ) - -`PinyinToIpa.cs` の `s_finalIpa` を基準とし、Misaki で異なる表記を使用する箇所を太字で示す。Misaki の主な差異は、二重母音の滑り音(off-glide/on-glide)に非音節化符号 (U+032F, COMBINING INVERTED BREVE BELOW) を付与する点にある。具体的には `ɪ` -> `i̯`、`ʊ` -> `u̯` に変換される。 - -| # | Final enum | ピンイン | PinyinToIpa(標準 IPA) | PinyinToMisaki | Unicode シーケンス | 差異 | -|---|-----------|---------|------------------------|----------------|-------------------|------| -| 1 | `A` | a | a | a | `a` | | -| 2 | `O` | o | o | o | `o` | | -| 3 | `E` | e | \u0264 (ɤ) | \u0264 (ɤ) | `\u0264` | | -| 4 | `Ai` | ai | a\u026A (aɪ) | **ai\u032F** (**ai̯**) | **`ai\u032F`** | **ɪ -> i + 非音節化符号** | -| 5 | `Ei` | ei | e\u026A (eɪ) | **ei\u032F** (**ei̯**) | **`ei\u032F`** | **ɪ -> i + 非音節化符号** | -| 6 | `Ao` | ao | a\u028A (aʊ) | **au\u032F** (**au̯**) | **`au\u032F`** | **ʊ -> u + 非音節化符号** | -| 7 | `Ou` | ou | o\u028A (oʊ) | **ou\u032F** (**ou̯**) | **`ou\u032F`** | **ʊ -> u + 非音節化符号** | -| 8 | `An` | an | an | an | `an` | | -| 9 | `En` | en | \u0259n (ən) | \u0259n (ən) | `\u0259n` | | -| 10 | `Ang` | ang | a\u014B (aŋ) | a\u014B (aŋ) | `a\u014B` | | -| 11 | `Eng` | eng | \u0259\u014B (əŋ) | \u0259\u014B (əŋ) | `\u0259\u014B` | | -| 12 | `Ong` | ong | \u028A\u014B (ʊŋ) | **u\u032F\u014B** (**u̯ŋ**) | **`u\u032F\u014B`** | **ʊ -> u + 非音節化符号** | -| 13 | `I` | i | i | i | `i` | | -| 14 | `Ia` | ia | ia | ia | `ia` | | -| 15 | `Ie` | ie | i\u025B (iɛ) | i\u025B (iɛ) | `i\u025B` | | -| 16 | `Iao` | iao | ia\u028A (iaʊ) | **iau\u032F** (**iau̯**) | **`iau\u032F`** | **ʊ -> u + 非音節化符号** | -| 17 | `Iu` | iu (iou) | io\u028A (ioʊ) | **iou\u032F** (**iou̯**) | **`iou\u032F`** | **ʊ -> u + 非音節化符号** | -| 18 | `Ian` | ian | i\u025Bn (iɛn) | i\u025Bn (iɛn) | `i\u025Bn` | | -| 19 | `In` | in | in | in | `in` | | -| 20 | `Iang` | iang | ia\u014B (iaŋ) | ia\u014B (iaŋ) | `ia\u014B` | | -| 21 | `Ing` | ing | i\u014B (iŋ) | i\u014B (iŋ) | `i\u014B` | | -| 22 | `Iong` | iong | i\u028A\u014B (iʊŋ) | **iu\u032F\u014B** (**iu̯ŋ**) | **`iu\u032F\u014B`** | **ʊ -> u + 非音節化符号** | -| 23 | `U` | u | u | u | `u` | | -| 24 | `Ua` | ua | ua | ua | `ua` | | -| 25 | `Uo` | uo | uo | uo | `uo` | | -| 26 | `Uai` | uai | ua\u026A (uaɪ) | **uai\u032F** (**uai̯**) | **`uai\u032F`** | **ɪ -> i + 非音節化符号** | -| 27 | `Ui` | ui (uei) | ue\u026A (ueɪ) | **uei\u032F** (**uei̯**) | **`uei\u032F`** | **ɪ -> i + 非音節化符号** | -| 28 | `Uan` | uan | uan | uan | `uan` | | -| 29 | `Un` | un (uen) | u\u0259n (uən) | u\u0259n (uən) | `u\u0259n` | | -| 30 | `Uang` | uang | ua\u014B (uaŋ) | ua\u014B (uaŋ) | `ua\u014B` | | -| 31 | `Ueng` | ueng | u\u0259\u014B (uəŋ) | u\u0259\u014B (uəŋ) | `u\u0259\u014B` | | -| 32 | `V` | u | y | y | `y` | | -| 33 | `Ve` | ue | y\u025B (yɛ) | y\u025B (yɛ) | `y\u025B` | | -| 34 | `Van` | uan | yan | yan | `yan` | | -| 35 | `Vn` | un | yn | yn | `yn` | | -| 36 | `Er` | er | \u0259\u027B (əɻ) | \u0259\u027B (əɻ) | `\u0259\u027B` | | +Phase 1-R で判明した Misaki の声母差異は以下の 6 箇所(旧仕様の 4 箇所から増加): + +1. **j** (Initial.J): 標準 IPA `tɕ` (U+0074 U+0255) → Misaki **`ʨ` (U+02A8 ラテン文字 TC ligature)** +2. **q** (Initial.Q): 標準 IPA `tɕʰ` → Misaki **`ʨʰ` (U+02A8 U+02B0)** +3. **z** (Initial.Z): 標準 IPA `ts` (2 文字) → Misaki **`ʦ` (U+02A6 ラテン文字 TS ligature)** +4. **c** (Initial.C): 標準 IPA `tsʰ` → Misaki **`ʦʰ` (U+02A6 U+02B0)** +5. **zh** (Initial.Zh): 標準 IPA `ʈʂ` (U+0288 U+0282) → Misaki **`ꭧ` (U+AB67 単一文字)** ※Phase 1-R で新規確定 +6. **ch** (Initial.Ch): 標準 IPA `ʈʂʰ` → Misaki **`ꭧʰ` (U+AB67 U+02B0)** ※Phase 1-R で新規確定 + +加えて **Y / W は声母層の 1 文字マッピングでは対応不可** であることが判明した。Misaki の "wang" は `wa→ŋ` だが、もし W を `w` 単独マップで処理すると韻母 Ang と組み合わせて `wa→ŋ` にならず `w` + `aŋ` = `waŋ→`(末尾声調)となる。そのため Y/W は後述の「Y/W 複合韻母マッピング」(セクション 3) で個別処理する。 + +### 2.2 韻母テーブル(36 エントリ、Prefix + Suffix 方式) + +**Phase 1-R で判明した最重要設計変更**: 声調矢印は Misaki では**韻母末尾ではなく韻母の中間**に挿入される。具体的には `man1` → `ma→n`(`man→` ではない)、`mang1` → `ma→ŋ`(`mang→` ではない)となる。これを扱うため、韻母テーブルは **Prefix + Suffix タプル** として定義する: + +```csharp +private static readonly Dictionary s_finalMisaki = new() +{ + [Final.Ai] = ("ai", ""), // prefix + tone + "" = ai→ + [Final.An] = ("a", "n"), // prefix + tone + suffix = a→n + [Final.Ang] = ("a", "\u014B"), // a→ŋ + // ... +}; +``` + +`ConvertSyllable` では `prefix + toneArrow + suffix` の順に結合する。Suffix が空文字の韻母は末尾付加と等価になる。 + +| # | Final enum | ピンイン | Prefix | Suffix | tone 1 例 | 備考 | gold 例 | +|---|-----------|---------|--------|--------|-----------|------|--------| +| 1 | `A` | a | `a` | `` | `a→` | | `la1` → `la→` | +| 2 | **`O`** | o | **`wo`** | `` | `wo→` | **bpmf + o は pwo/pʰwo/mwo/fwo 形式** | `bo1` → **`pwo→`** | +| 3 | `E` | e | `\u0264` (ɤ) | `` | `ɤ→` | | `le1` → `lɤ→` | +| 4 | **`Ai`** | ai | **`ai`** | `` | `ai→` | **U+032F strip 後(非音節化符号なし)** | `lai1` → `lai→` | +| 5 | **`Ei`** | ei | **`ei`** | `` | `ei→` | **U+032F strip 後** | `lei1` → `lei→` | +| 6 | **`Ao`** | ao | **`au`** | `` | `au→` | **Misaki "au"、strip 後** | `lao1` → `lau→` | +| 7 | **`Ou`** | ou | **`ou`** | `` | `ou→` | **strip 後** | `lou1` → `lou→` | +| 8 | **`An`** | an | **`a`** | **`n`** | **`a→n`** | **声調が中間** | `lan1` → **`la→n`** | +| 9 | **`En`** | en | **`\u0259`** (ə) | **`n`** | **`ə→n`** | **声調が中間、U+0259** | `len1` → **`lə→n`** | +| 10 | **`Ang`** | ang | **`a`** | **`\u014B`** (ŋ) | **`a→ŋ`** | **声調が中間、U+014B** | `lang1` → **`la→ŋ`** | +| 11 | **`Eng`** | eng | **`\u0259`** (ə) | **`\u014B`** (ŋ) | **`ə→ŋ`** | **声調が中間** | `leng1` → **`lə→ŋ`** | +| 12 | **`Ong`** | ong | **`\u028A`** (ʊ) | **`\u014B`** (ŋ) | **`ʊ→ŋ`** | **U+028A ʊ(旧 `u̯` は誤り)、声調が中間** | `long1` → **`lʊ→ŋ`** / `dong1` → `tʊ→ŋ` | +| 13 | `I` | i | `i` | `` | `i→` | | `li1` → `li→` | +| 14 | **`Ia`** | ia | **`ja`** | `` | `ja→` | **j 半母音** | `lia1` → **`lja→`** | +| 15 | **`Ie`** | ie | **`je`** | `` | `je→` | **Misaki "je" (標準 IPA の `iɛ` とは違う、NOT `jɛ`)** | `lie1` → **`lje→`** | +| 16 | **`Iao`** | iao | **`jau`** | `` | `jau→` | **j 半母音、strip 後** | `liao1` → **`ljau→`** | +| 17 | **`Iu`** | iu (iou) | **`jou`** | `` | `jou→` | **Misaki "iou"、strip 後** | `liu1` → **`ljou→`** | +| 18 | **`Ian`** | ian | **`j\u025B`** (jɛ) | **`n`** | **`jɛ→n`** | **j 半母音、ɛ (U+025B)、声調が中間** | `lian1` → **`ljɛ→n`** | +| 19 | **`In`** | in | **`i`** | **`n`** | **`i→n`** | **j なし、声調が中間** | `lin1` → **`li→n`** | +| 20 | **`Iang`** | iang | **`ja`** | **`\u014B`** (ŋ) | **`ja→ŋ`** | **j 半母音、声調が中間** | `liang1` → **`lja→ŋ`** | +| 21 | **`Ing`** | ing | **`i`** | **`\u014B`** (ŋ) | **`i→ŋ`** | **j なし、声調が中間** | `ling1` → **`li→ŋ`** | +| 22 | **`Iong`** | iong | **`j\u028A`** (jʊ) | **`\u014B`** (ŋ) | **`jʊ→ŋ`** | **j + ʊ、声調が中間** | `xiong2` → **`ɕjʊ↗ŋ`** | +| 23 | `U` | u | `u` | `` | `u→` | | `lu1` → `lu→` | +| 24 | **`Ua`** | ua | **`wa`** | `` | `wa→` | **w 半母音** | `lua1` → **`lwa→`** | +| 25 | **`Uo`** | uo | **`wo`** | `` | `wo→` | **w 半母音** | `luo1` → **`lwo→`** | +| 26 | **`Uai`** | uai | **`wai`** | `` | `wai→` | **strip 後** | `guai1` → **`kwai→`** | +| 27 | **`Ui`** | ui (uei) | **`wei`** | `` | `wei→` | **Misaki "uei"、strip 後** | `guei1` → **`kwei→`** | +| 28 | **`Uan`** | uan | **`wa`** | **`n`** | **`wa→n`** | **声調が中間** | `luan1` → **`lwa→n`** / `guan1` → `kwa→n` | +| 29 | **`Un`** | un (uen) | **`w\u0259`** (wə) | **`n`** | **`wə→n`** | **Misaki "uen"、声調が中間** | `lun1` → **`lwə→n`** / `guen1` → `kwə→n` | +| 30 | **`Uang`** | uang | **`wa`** | **`\u014B`** (ŋ) | **`wa→ŋ`** | **声調が中間** | `luang1` → **`lwa→ŋ`** | +| 31 | **`Ueng`** | ueng | **`w\u0259`** (wə) | **`\u014B`** (ŋ) | **`wə→ŋ`** | **声調が中間** | `gueng1` → **`kwə→ŋ`** | +| 32 | `V` (ü) | u | `y` | `` | `y→` | U+0079 | `lv1` → `ly→` | +| 33 | **`Ve`** (üe) | ue | **`\u0265e`** (ɥe) | `` | `ɥe→` | **ɥ = U+0265(NOT y)** | `lve1` → **`lɥe→`** / `jve1` → `ʨɥe→` | +| 34 | **`Van`** (üan) | uan | **`\u0265\u025B`** (ɥɛ) | **`n`** | **`ɥɛ→n`** | **ɥ + ɛ + n、声調が中間** | `jvan1` → **`ʨɥɛ→n`** | +| 35 | `Vn` (ün) | un | `y` | **`n`** | **`y→n`** | **声調が中間** | `jvn1` → **`ʨy→n`** | +| 36 | **`Er`** | er | **`\u025A`** (ɚ) | `` | `ɚ→` | **U+025A 単一記号(NOT `əɻ`)** | `er1` → **`ɚ→`** | **差異まとめ(韻母):** -全 7 箇所の差異は以下の 2 パターンに分類される: +1. **声調位置**: An/En/Ang/Eng/Ong/In/Ing/Ian/Iang/Iong/Uan/Un/Uang/Ueng/Van/Vn の **16 韻母で声調が Prefix と Suffix の間に挿入される**(旧仕様の「末尾付加」は誤り)。 +2. **U+032F 非音節化符号なし**: Ai/Ei/Ao/Ou/Iao/Iu/Uai/Ui の 8 韻母は Misaki ではシンプルな `ai`/`ei`/`au`/`ou`/`jau`/`jou`/`wai`/`wei` である(旧仕様の `ai̯`/`ei̯`/`au̯`/`ou̯` は誤り)。 +3. **Ong は `ʊŋ`**: Misaki は `ʊ` (U+028A) を使用する(旧仕様の `u̯ŋ` (U+0075 U+032F U+014B) は誤り)。 +4. **Ian は `jɛn`**: Misaki は `j + ɛ + n` を使用する(旧仕様の `iɛn` は誤り、先頭が半母音 j)。 +5. **Ie は `je` 単純形**: Misaki は `je` であり `jɛ` ではない(旧仕様の `iɛ` とは異なる)。 +6. **Ve は `ɥe`、Van は `ɥɛn`**: Misaki は ɥ (U+0265 LATIN SMALL LETTER TURNED H) を使用する(旧仕様の `y`/`yan` は誤り)。 +7. **bpmf + o は `wo`**: Misaki で `bo1` → `pwo→` のように w が挿入される。韻母 O は Prefix=`wo` として定義する。 +8. **Er は `ɚ` 単独**: Misaki は U+025A (SCHWA WITH HOOK) 単一記号(旧仕様の `əɻ` (U+0259 U+027B) は誤り)。 -1. **ɪ (U+026A) -> i + 非音節化符号 (U+032F)**: Ai, Ei, Uai, Ui の 4 韻母 -2. **ʊ (U+028A) -> u + 非音節化符号 (U+032F)**: Ao, Ou, Ong, Iao, Iu, Iong の 6 韻母 +### 2.3 声調矢印テーブル(5 エントリ) -**特殊韻母(そり舌・歯茎)について:** +PinyinToIpa が IPA tone letters を使用するのに対し、Misaki は矢印記号を使用する。矢印の挿入位置は韻母の Prefix と Suffix の間(セクション 2.2 参照)。 -PinyinToIpa では `zh/ch/sh/r + i` をそり舌母音 `ɻ̩` (U+027B U+0329)、`z/c/s + i` を歯茎母音 `ɹ̩` (U+0279 U+0329) に変換するが、Misaki ではこれらをそのまま踏襲する(変更なし)。 +| # | Tone enum | 声調名 | PinyinToIpa(IPA tone letters) | PinyinToMisaki(矢印記号) | Unicode シーケンス | +|---|----------|-------|-------------------------------|--------------------------|-------------------| +| 1 | `Neutral` (0) | 軽声 | (なし) | (なし) | `""` | +| 2 | `First` (1) | 陰平 (55) | `\u02E5\u02E5` (˥˥) | **`→`** | **`\u2192`** | +| 3 | `Second` (2) | 陽平 (35) | `\u02E7\u02E5` (˧˥) | **`↗`** | **`\u2197`** | +| 4 | `Third` (3) | 上声 (214) | `\u02E8\u02E9\u02E6` (˨˩˦) | **`↓`** | **`\u2193`** | +| 5 | `Fourth` (4) | 去声 (51) | `\u02E5\u02E9` (˥˩) | **`↘`** | **`\u2198`** | -### 2.3 声調テーブル(5 エントリ) +全 4 声調(軽声を除く)が異なる。IPA tone letters(複数文字の声調レベル記号)から、単一の Unicode 矢印記号に変更される。Phase 1-R 検証済み: `ma1/2/3/4/5` → `ma→ / ma↗ / ma↓ / ma↘ / ma` -PinyinToIpa が IPA tone letters を使用するのに対し、Misaki は矢印記号を使用する。 +### 2.4 特殊母音ケース(Final enum ではない差し替え、3 ケース) -| # | Tone enum | 声調名 | PinyinToIpa(IPA tone letters) | PinyinToMisaki(矢印記号) | Unicode シーケンス | 差異 | -|---|----------|-------|-------------------------------|--------------------------|-------------------|------| -| 1 | `Neutral` (0) | 軽声 | (なし) | (なし) | `""` | | -| 2 | `First` (1) | 陰平 (55) | **\u02E5\u02E5** (**˥˥**) | **\u2192** (**→**) | **`\u2192`** | **tone letters -> 矢印** | -| 3 | `Second` (2) | 陽平 (35) | **\u02E7\u02E5** (**˧˥**) | **\u2197** (**↗**) | **`\u2197`** | **tone letters -> 矢印** | -| 4 | `Third` (3) | 上声 (214) | **\u02E8\u02E9\u02E6** (**˨˩˦**) | **\u2193** (**↓**) | **`\u2193`** | **tone letters -> 矢印** | -| 5 | `Fourth` (4) | 去声 (51) | **\u02E5\u02E9** (**˥˩**) | **\u2198** (**↘**) | **`\u2198`** | **tone letters -> 矢印** | +以下は Final enum に一律マップするのではなく、`(Initial, Final)` コンテキストで差し替える特殊ケース: -**差異まとめ(声調):** +| # | Context | Prefix | Suffix | 備考 | gold 例 | +|---|---------|--------|--------|------|--------| +| 1 | **Zh/Ch/Sh/R + Final.I** | `\u0268` (ɨ) | `` | **そり舌そり頂母音 (retroflex apical) → U+0268 直接**(旧仕様の `ɻ̩` (U+027B U+0329) は誤り) | `zhi4` → `ꭧɨ↘` / `ri3` → `ɻɨ↓` | +| 2 | **Z/C/S + Final.I** | `\u0268` (ɨ) | `` | **歯茎そり頂母音 (alveolar apical) → U+0268 直接**(旧仕様の `ɹ̩` (U+0279 U+0329) は誤り) | `zi4` → `ʦɨ↘` / `si2` → `sɨ↗` | +| 3 | **Initial.None + Final.O** | `\u0254` (ɔ) | `` | **単独感嘆詞 ō → U+0254 ɔ**(bpmf + o の `wo` とは異なる) | `o1` → **`ɔ→`** / `o4` → `ɔ↘` | -全 4 声調(軽声を除く)が異なる。IPA tone letters(複数文字の声調レベル記号)から、単一の Unicode 矢印記号に変更される。 +**retroflex / alveolar apical の新仕様**: Phase 1-R で Misaki 公式実装は zh/ch/sh/r + i と z/c/s + i の両方で直接 `ɨ` (U+0268 CLOSE CENTRAL UNROUNDED VOWEL) を出力することが判明した。旧仕様の `ɻ̩` / `ɹ̩`(結合音節主音記号 U+0329)は誤りで、Misaki は区別せず ɨ 単一記号を使用する。 -### 2.4 実装ファイル +**単独感嘆詞 o の分岐**: Misaki では bpmf + o (`bo`/`po`/`mo`/`fo`) は `pwo`/`pʰwo`/`mwo`/`fwo` 形式で Final.O = `(wo, "")` を使う一方、単独の `o` (`ō`/`ó`/`ǒ`/`ò`) は `ɔ` (U+0254) となる。`Initial.None + Final.O` のコンテキストでのみ `(ɔ, "")` に差し替える。 -**新規作成:** `src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs` +### 2.5 実装ファイル + +**新規作成:** `src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs` のみ。 ```csharp internal static class PinyinToMisaki { - // 声母テーブル: Dictionary - // 韻母テーブル: Dictionary - // 声調テーブル: string[] - // そり舌母音・歯茎母音: PinyinToIpa と同一 + // 声母テーブル: Dictionary(Y/W は含めない、None も含めない) + private static readonly Dictionary s_initialMisaki = new() + { + [Initial.B] = "p", + [Initial.P] = "p\u02B0", + // ... + [Initial.J] = "\u02A8", // Misaki差異: tɕ→ʨ (U+02A8 ligature) + [Initial.Q] = "\u02A8\u02B0", // Misaki差異: tɕʰ→ʨʰ + [Initial.X] = "\u0255", + [Initial.Zh] = "\uAB67", // Misaki差異: ʈʂ→ꭧ (U+AB67) + [Initial.Ch] = "\uAB67\u02B0", // Misaki差異: ʈʂʰ→ꭧʰ + [Initial.Sh] = "\u0282", + [Initial.R] = "\u027B", + [Initial.Z] = "\u02A6", // Misaki差異: ts→ʦ (U+02A6 ligature) + [Initial.C] = "\u02A6\u02B0", // Misaki差異: tsʰ→ʦʰ + [Initial.S] = "s", + // Y/W は含めない(compound final 層で処理) + }; + + // 韻母テーブル: Dictionary + private static readonly Dictionary s_finalMisaki = new() + { + [Final.A] = ("a", ""), + [Final.O] = ("wo", ""), // bpmf+o 用、単独 o は特殊ケース + [Final.E] = ("\u0264", ""), // ɤ + [Final.Ai] = ("ai", ""), + [Final.Ei] = ("ei", ""), + [Final.Ao] = ("au", ""), + [Final.Ou] = ("ou", ""), + [Final.An] = ("a", "n"), // 声調が中間 + [Final.En] = ("\u0259", "n"), // 声調が中間 + [Final.Ang] = ("a", "\u014B"), // 声調が中間 + [Final.Eng] = ("\u0259", "\u014B"), // 声調が中間 + [Final.Ong] = ("\u028A", "\u014B"), // ʊŋ、声調が中間 + [Final.I] = ("i", ""), + [Final.Ia] = ("ja", ""), + [Final.Ie] = ("je", ""), // NOT jɛ + [Final.Iao] = ("jau", ""), + [Final.Iu] = ("jou", ""), + [Final.Ian] = ("j\u025B", "n"), // jɛn、声調が中間 + [Final.In] = ("i", "n"), // 声調が中間 + [Final.Iang] = ("ja", "\u014B"), // 声調が中間 + [Final.Ing] = ("i", "\u014B"), // 声調が中間 + [Final.Iong] = ("j\u028A", "\u014B"), // jʊŋ、声調が中間 + [Final.U] = ("u", ""), + [Final.Ua] = ("wa", ""), + [Final.Uo] = ("wo", ""), + [Final.Uai] = ("wai", ""), + [Final.Ui] = ("wei", ""), + [Final.Uan] = ("wa", "n"), // 声調が中間 + [Final.Un] = ("w\u0259", "n"), // wən、声調が中間 + [Final.Uang] = ("wa", "\u014B"), // 声調が中間 + [Final.Ueng] = ("w\u0259", "\u014B"), // 声調が中間 + [Final.V] = ("y", ""), + [Final.Ve] = ("\u0265e", ""), // ɥe (U+0265) + [Final.Van] = ("\u0265\u025B", "n"), // ɥɛn、声調が中間 + [Final.Vn] = ("y", "n"), // 声調が中間 + [Final.Er] = ("\u025A", ""), // ɚ + }; + + // 声調矢印テーブル: string[] + private static readonly string[] s_toneArrows = new[] + { + "", // Neutral (0) + "\u2192", // First (1) → + "\u2197", // Second (2) ↗ + "\u2193", // Third (3) ↓ + "\u2198", // Fourth (4) ↘ + }; + + // Y/W 複合韻母テーブル(セクション 3 参照) + // キー: (Initial, Final)、値: (Prefix, Suffix, OmitInitial) + private static readonly Dictionary<(Initial, Final), (string Prefix, string Suffix, bool OmitInitial)> + s_yWCompoundMisaki = new() + { + // Y 系 + [(Initial.Y, Final.A)] = ("ja", "", false), + [(Initial.Y, Final.An)] = ("j\u025B", "n", false), + [(Initial.Y, Final.Ang)] = ("ja", "\u014B", false), + [(Initial.Y, Final.Ao)] = ("jau", "", false), + [(Initial.Y, Final.E)] = ("je", "", false), + [(Initial.Y, Final.I)] = ("i", "", true), + [(Initial.Y, Final.In)] = ("i", "n", true), + [(Initial.Y, Final.Ing)] = ("i", "\u014B", true), + [(Initial.Y, Final.Ong)] = ("j\u028A", "\u014B", false), + [(Initial.Y, Final.Ou)] = ("jou", "", false), + [(Initial.Y, Final.V)] = ("y", "", true), + [(Initial.Y, Final.Ve)] = ("\u0265e", "", false), + [(Initial.Y, Final.Van)] = ("\u0265\u025B", "n", false), + [(Initial.Y, Final.Vn)] = ("y", "n", true), + // W 系 + [(Initial.W, Final.A)] = ("wa", "", false), + [(Initial.W, Final.Ai)] = ("wai", "", false), + [(Initial.W, Final.An)] = ("wa", "n", false), + [(Initial.W, Final.Ang)] = ("wa", "\u014B", false), + [(Initial.W, Final.Ei)] = ("wei", "", false), + [(Initial.W, Final.En)] = ("w\u0259", "n", false), + [(Initial.W, Final.Eng)] = ("w\u0259", "\u014B", false), + [(Initial.W, Final.O)] = ("wo", "", false), + [(Initial.W, Final.U)] = ("u", "", true), + }; } ``` -テーブルのみを定義し、Convert メソッドは T02 で実装する。ただし、テーブル参照のための internal static なアクセサ(`GetInitialIpa`, `GetFinalIpa`, `GetToneMarker` 等)は本チケットで定義してもよい。 +テーブルのみを定義し、Convert メソッドは T02 で実装する。ただし、テーブル参照のための internal static なアクセサ(`GetInitialMisaki`, `GetFinalMisaki`, `GetToneArrow` 等)は本チケットで定義してもよい。 + +## 3. Y/W 複合韻母変換表(23 エントリ、Phase 1-R 新規導入セクション) -## 3. 実装するために必要なエージェントチームの役割と人数 +### 3.1 背景 + +DotNetG2P の `PinyinParser` は "wang" を `Initial.W + Final.Ang`、"yan" を `Initial.Y + Final.An` のように parse する。これに対し Misaki の元実装では "wang" は `uang` という複合韻母として、"yan" は `ian` として扱われる。構造が異なるため、T02 の `ConvertSyllable` で `(Initial, Final)` ペアが Y/W 系の場合は以下の複合韻母テーブルを優先参照する必要がある。 + +Phase 1-R gold standard での検証済み(`ya1/ye1/yi1/wa1/wo1/wu1` 等 23 パターン全件実測): + +| # | Initial | Final | → Misaki 等価 | Prefix | Suffix | Initial 省略? | gold 検証 | +|---|---------|-------|--------------|--------|--------|---------------|----------| +| 1 | Y | A | Ia | `ja` | `` | No | `ya1` → `ja→` | +| 2 | Y | An | Ian | `j\u025B` (jɛ) | `n` | No | `yan1` → `jɛ→n` | +| 3 | Y | Ang | Iang | `ja` | `\u014B` (ŋ) | No | `yang1` → `ja→ŋ` | +| 4 | Y | Ao | Iao | `jau` | `` | No | `yao1` → `jau→` | +| 5 | Y | E | Ie | `je` | `` | No | `ye1` → `je→` | +| 6 | Y | I | I | `i` | `` | **Yes(j 省略)** | `yi1` → `i→`(`ji→` ではない) | +| 7 | Y | In | In | `i` | `n` | **Yes** | `yin1` → `i→n` | +| 8 | Y | Ing | Ing | `i` | `\u014B` (ŋ) | **Yes** | `ying1` → `i→ŋ` | +| 9 | Y | Ong | Iong | `j\u028A` (jʊ) | `\u014B` (ŋ) | No | `yong1` → `jʊ→ŋ` | +| 10 | Y | Ou | Iu (iou) | `jou` | `` | No | `you1` → `jou→` | +| 11 | Y | V | V (ü) | `y` | `` | **Yes(ɥ 省略)** | `yu1` → `y→`(`ɥy→` ではない) | +| 12 | Y | Ve | Ve (üe) | `\u0265e` (ɥe) | `` | No | `yue1` → `ɥe→` | +| 13 | Y | Van | Van (üan) | `\u0265\u025B` (ɥɛ) | `n` | No | `yuan1` → `ɥɛ→n` | +| 14 | Y | Vn | Vn (ün) | `y` | `n` | **Yes(ɥ 省略)** | `yun1` → `y→n` | +| 15 | W | A | Ua | `wa` | `` | No | `wa1` → `wa→` | +| 16 | W | Ai | Uai | `wai` | `` | No | `wai1` → `wai→` | +| 17 | W | An | Uan | `wa` | `n` | No | `wan1` → `wa→n` | +| 18 | W | Ang | Uang | `wa` | `\u014B` (ŋ) | No | `wang1` → `wa→ŋ` | +| 19 | W | Ei | Ui (uei) | `wei` | `` | No | `wei1` → `wei→` | +| 20 | W | En | Un (uen) | `w\u0259` (wə) | `n` | No | `wen1` → `wə→n` | +| 21 | W | Eng | Ueng | `w\u0259` (wə) | `\u014B` (ŋ) | No | `weng1` → `wə→ŋ` | +| 22 | W | O | Uo | `wo` | `` | No | `wo1` → `wo→` | +| 23 | W | U | U | `u` | `` | **Yes(w 省略)** | `wu1` → `u→`(`wu→` ではない) | + +### 3.2 Initial 省略ルールの要点 + +計 5 エントリで Initial を省略する: + +- **`yi/yin/ying` (Y + I/In/Ing)**: j を出力せず `i`/`i→n`/`i→ŋ` となる +- **`yu/yun` (Y + V/Vn)**: ɥ を出力せず `y`/`y→n` となる +- **`wu` (W + U)**: w を出力せず `u→` となる + +残り 18 エントリは Initial 省略なし(prefix がすでに半母音 `j` または `w` を含む)。 + +### 3.3 ConvertSyllable パイプライン(T02 スコープだが T01 でマッピング設計の根拠として記載) + +``` +ConvertSyllable(syllable, includeTones): + 1. 声調矢印決定: includeTones && tone != Neutral ? s_toneArrows[(int)tone] : "" + 2. 特別ケース判定: + a. Initial.None + Final.O → return "ɔ" + toneArrow (単独感嘆詞) + b. Final.Er → return "ɚ" + toneArrow (Er 単独) + c. Zh/Ch/Sh/R + Final.I → return s_initialMisaki[initial] + "ɨ" + toneArrow + d. Z/C/S + Final.I → return s_initialMisaki[initial] + "ɨ" + toneArrow + 3. Y/W + Final 変換判定: ルックアップ s_yWCompoundMisaki[(initial, final)] + hit → (prefix, suffix, omitInitial) を取得 + miss → standard path: prefix = s_initialMisaki[initial] (if any), (prefix, suffix) = s_finalMisaki[final] + 4. 構築: + if (!omitInitial && initial != None) sb.Append(s_initialMisaki[initial]) + sb.Append(prefix) + sb.Append(toneArrow) + sb.Append(suffix) + 5. return sb.ToString() +``` + +**注意**: U+032F は事前に strip 済みのテンプレートを使うので、`ConvertSyllable` 最後で `.Replace("\u032F", "")` は不要。同様に `ɻ̩`/`ɹ̩` → `ɨ` は retroflex/alveolar テンプレを直接 `("ɨ", "")` にすることで対応済み。 + +## 4. 実装するために必要なエージェントチームの役割と人数 | 役割 | 人数 | 担当内容 | |------|------|---------| -| 実装エージェント | 1 名 | `PinyinToMisaki.cs` のテーブル定義コード作成、Unicode エスケープシーケンスの正確な記述 | -| テストエージェント | 1 名 | マッピングテーブルの全エントリに対するユニットテスト作成 | -| レビューエージェント | 1 名 | Unicode コードポイントの正確性検証、Misaki 公式実装との照合、言語学的正確性確認 | +| 実装エージェント | 1 名 | `PinyinToMisaki.cs` の 4 テーブル定義コード作成、Unicode エスケープシーケンスの正確な記述、Prefix+Suffix タプル実装 | +| テストエージェント | 1 名 | マッピングテーブルの全エントリに対するユニットテスト作成、gold standard 137 件との照合テスト | +| Unicode レビューエージェント | 1 名 | Unicode コードポイントの正確性検証(U+02A8 / U+02A6 / U+AB67 / U+0265 / U+028A / U+025A / U+0254 / U+0268 等)、Kokoro vocab 互換性確認、Misaki 公式実装との再照合 | **合計: 3 名** -実装自体はテーブル定義のみのため小規模だが、Unicode 文字の正確性が極めて重要であるため、レビューエージェントの参加が必須である。 +実装自体はテーブル定義のみのため小規模だが、Unicode 文字の正確性が極めて重要であるため、Unicode レビューエージェントの参加が必須である。Phase 1-R で判明した 12 項目の差分(セクション 1 参照)を全てテストで検出する必要がある。 -## 4. 提供範囲とテスト項目 +## 5. 提供範囲とテスト項目 ### スコープ -- `PinyinToMisaki.cs` 内のマッピングテーブル(`Dictionary`、`Dictionary`、`string[]`)の定義 -- テーブルのキーが全 enum 値を網羅していることの保証 +- `PinyinToMisaki.cs` 内の 4 マッピングテーブル: + - 声母テーブル 22 エントリ(`Dictionary`、Y/W を除く、None を除く) + - 韻母テーブル 36 エントリ(`Dictionary`) + - 声調矢印テーブル 5 エントリ(`string[]`) + - Y/W 複合韻母テーブル 23 エントリ(`Dictionary<(Initial, Final), (string, string, bool)>`) +- 特殊母音 3 ケース(Zh/Ch/Sh/R + I、Z/C/S + I、Initial.None + O)の定数定義 +- テーブルのキーが全 enum 値を網羅していることの保証(メタテスト) - 各テーブルエントリに対するユニットテスト +- gold standard 137 件(`.claude/tmp/misaki-gold.txt`)との照合テスト(T02 で実施、T01 ではテーブル単体の検証のみ) ### スコープ外 -- Convert メソッドの実装(T02) -- ChineseG2PEngine への統合(T02 以降) +- `ConvertSyllable` メソッドの実装(T02) +- `ChineseG2PEngine` への統合(T02 以降) - 既存の ToIpa / ToPiperIpa / ToZhuyin API への影響(なし) ### ユニットテスト項目 -**テストクラス:** `tests/DotNetG2P.Tests/Chinese/PinyinToMisakiMappingTests.cs` +**テストクラス:** `tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiMappingTests.cs` #### 声母テスト(22 件) @@ -197,61 +418,61 @@ internal static class PinyinToMisaki | `InitialG_MapsToK` | `Initial.G` | `"k"` | 同一 | | `InitialK_MapsToKh` | `Initial.K` | `"k\u02B0"` | 同一 | | `InitialH_MapsToX` | `Initial.H` | `"x"` | 同一 | -| **`InitialJ_MapsToTcLigature`** | `Initial.J` | `"\u02A8"` | **合字 U+02A8** | -| **`InitialQ_MapsToTcLigatureAspirated`** | `Initial.Q` | `"\u02A8\u02B0"` | **合字 + 有気** | +| **`InitialJ_MapsToTcLigature`** | `Initial.J` | `"\u02A8"` | **U+02A8 ligature** | +| **`InitialQ_MapsToTcLigatureAspirated`** | `Initial.Q` | `"\u02A8\u02B0"` | **U+02A8 + 有気** | | `InitialX_MapsToAlveolopalatalFricative` | `Initial.X` | `"\u0255"` | 同一 | -| `InitialZh_MapsToRetroflexAffricate` | `Initial.Zh` | `"\u0288\u0282"` | 同一 | -| `InitialCh_MapsToRetroflexAffricateAspirated` | `Initial.Ch` | `"\u0288\u0282\u02B0"` | 同一 | +| **`InitialZh_MapsToAb67`** | `Initial.Zh` | `"\uAB67"` | **U+AB67 単一文字** | +| **`InitialCh_MapsToAb67Aspirated`** | `Initial.Ch` | `"\uAB67\u02B0"` | **U+AB67 + 有気** | | `InitialSh_MapsToRetroflexFricative` | `Initial.Sh` | `"\u0282"` | 同一 | | `InitialR_MapsToRetroflexApproximant` | `Initial.R` | `"\u027B"` | 同一 | -| **`InitialZ_MapsToTsLigature`** | `Initial.Z` | `"\u02A6"` | **合字 U+02A6** | -| **`InitialC_MapsToTsLigatureAspirated`** | `Initial.C` | `"\u02A6\u02B0"` | **合字 + 有気** | +| **`InitialZ_MapsToTsLigature`** | `Initial.Z` | `"\u02A6"` | **U+02A6 ligature** | +| **`InitialC_MapsToTsLigatureAspirated`** | `Initial.C` | `"\u02A6\u02B0"` | **U+02A6 + 有気** | | `InitialS_MapsToS` | `Initial.S` | `"s"` | 同一 | -| `InitialY_MapsToPalatalApproximant` | `Initial.Y` | `"j"` | 同一 | -| `InitialW_MapsToLabialVelarApproximant` | `Initial.W` | `"w"` | 同一 | - -#### 韻母テスト(32 件) -各 `Final` enum 値に対して、テーブルから取得した文字列が期待する Unicode シーケンスと完全一致することを検証する。差異のある 10 韻母を重点的にテストする。 - -| テストケース | 入力 | 期待出力 | 検証ポイント | -|------------|------|---------|------------| -| `FinalA_MapsToA` | `Final.A` | `"a"` | 同一 | -| `FinalO_MapsToO` | `Final.O` | `"o"` | 同一 | -| `FinalE_MapsToRamishorn` | `Final.E` | `"\u0264"` | 同一 | -| **`FinalAi_MapsToAiWithInvertedBreve`** | `Final.Ai` | `"ai\u032F"` | **非音節化符号** | -| **`FinalEi_MapsToEiWithInvertedBreve`** | `Final.Ei` | `"ei\u032F"` | **非音節化符号** | -| **`FinalAo_MapsToAuWithInvertedBreve`** | `Final.Ao` | `"au\u032F"` | **非音節化符号** | -| **`FinalOu_MapsToOuWithInvertedBreve`** | `Final.Ou` | `"ou\u032F"` | **非音節化符号** | -| `FinalAn_MapsToAn` | `Final.An` | `"an"` | 同一 | -| `FinalEn_MapsToSchwan` | `Final.En` | `"\u0259n"` | 同一 | -| `FinalAng_MapsToAng` | `Final.Ang` | `"a\u014B"` | 同一 | -| `FinalEng_MapsToSchwaEng` | `Final.Eng` | `"\u0259\u014B"` | 同一 | -| **`FinalOng_MapsToUInvertedBreveNg`** | `Final.Ong` | `"u\u032F\u014B"` | **非音節化符号** | -| `FinalI_MapsToI` | `Final.I` | `"i"` | 同一 | -| `FinalIa_MapsToIa` | `Final.Ia` | `"ia"` | 同一 | -| `FinalIe_MapsToIOpenE` | `Final.Ie` | `"i\u025B"` | 同一 | -| **`FinalIao_MapsToIauWithInvertedBreve`** | `Final.Iao` | `"iau\u032F"` | **非音節化符号** | -| **`FinalIu_MapsToIouWithInvertedBreve`** | `Final.Iu` | `"iou\u032F"` | **非音節化符号** | -| `FinalIan_MapsToIOpenEn` | `Final.Ian` | `"i\u025Bn"` | 同一 | -| `FinalIn_MapsToIn` | `Final.In` | `"in"` | 同一 | -| `FinalIang_MapsToIaEng` | `Final.Iang` | `"ia\u014B"` | 同一 | -| `FinalIng_MapsToIEng` | `Final.Ing` | `"i\u014B"` | 同一 | -| **`FinalIong_MapsToIuInvertedBreveNg`** | `Final.Iong` | `"iu\u032F\u014B"` | **非音節化符号** | -| `FinalU_MapsToU` | `Final.U` | `"u"` | 同一 | -| `FinalUa_MapsToUa` | `Final.Ua` | `"ua"` | 同一 | -| `FinalUo_MapsToUo` | `Final.Uo` | `"uo"` | 同一 | -| **`FinalUai_MapsToUaiWithInvertedBreve`** | `Final.Uai` | `"uai\u032F"` | **非音節化符号** | -| **`FinalUi_MapsToUeiWithInvertedBreve`** | `Final.Ui` | `"uei\u032F"` | **非音節化符号** | -| `FinalUan_MapsToUan` | `Final.Uan` | `"uan"` | 同一 | -| `FinalUn_MapsToUSchwan` | `Final.Un` | `"u\u0259n"` | 同一 | -| `FinalUang_MapsToUaEng` | `Final.Uang` | `"ua\u014B"` | 同一 | -| `FinalUeng_MapsToUSchwaEng` | `Final.Ueng` | `"u\u0259\u014B"` | 同一 | -| `FinalV_MapsToY` | `Final.V` | `"y"` | 同一 | -| `FinalVe_MapsToYOpenE` | `Final.Ve` | `"y\u025B"` | 同一 | -| `FinalVan_MapsToYan` | `Final.Van` | `"yan"` | 同一 | -| `FinalVn_MapsToYn` | `Final.Vn` | `"yn"` | 同一 | -| `FinalEr_MapsToSchwaRetroflex` | `Final.Er` | `"\u0259\u027B"` | 同一 | +※Y/W は声母テーブルに含めない。`InitialY_NotInInitialTable` / `InitialW_NotInInitialTable` としてテーブル非含有を検証する。 + +#### 韻母テスト(36 件、Prefix + Suffix タプル) + +各 `Final` enum 値に対して、テーブルから取得した `(Prefix, Suffix)` タプルが期待する Unicode シーケンスと完全一致することを検証する。 + +| テストケース | 入力 | 期待 Prefix | 期待 Suffix | 検証ポイント | +|------------|------|------------|------------|------------| +| `FinalA_MapsToAEmpty` | `Final.A` | `"a"` | `""` | 同一 | +| **`FinalO_MapsToWoEmpty`** | `Final.O` | `"wo"` | `""` | **bpmf+o 用** | +| `FinalE_MapsToRamishornEmpty` | `Final.E` | `"\u0264"` | `""` | ɤ | +| **`FinalAi_MapsToAiNoNonSyllabic`** | `Final.Ai` | `"ai"` | `""` | **U+032F なし** | +| **`FinalEi_MapsToEiNoNonSyllabic`** | `Final.Ei` | `"ei"` | `""` | **U+032F なし** | +| **`FinalAo_MapsToAu`** | `Final.Ao` | `"au"` | `""` | **Misaki au** | +| **`FinalOu_MapsToOu`** | `Final.Ou` | `"ou"` | `""` | **U+032F なし** | +| **`FinalAn_MapsToASplitN`** | `Final.An` | `"a"` | `"n"` | **声調が中間** | +| **`FinalEn_MapsToSchwaSplitN`** | `Final.En` | `"\u0259"` | `"n"` | **声調が中間** | +| **`FinalAng_MapsToASplitNg`** | `Final.Ang` | `"a"` | `"\u014B"` | **声調が中間** | +| **`FinalEng_MapsToSchwaSplitNg`** | `Final.Eng` | `"\u0259"` | `"\u014B"` | **声調が中間** | +| **`FinalOng_MapsToUpperUSplitNg`** | `Final.Ong` | `"\u028A"` | `"\u014B"` | **ʊŋ** | +| `FinalI_MapsToIEmpty` | `Final.I` | `"i"` | `""` | 同一 | +| **`FinalIa_MapsToJa`** | `Final.Ia` | `"ja"` | `""` | **j 半母音** | +| **`FinalIe_MapsToJe`** | `Final.Ie` | `"je"` | `""` | **Misaki je (not jɛ)** | +| **`FinalIao_MapsToJau`** | `Final.Iao` | `"jau"` | `""` | **j + au** | +| **`FinalIu_MapsToJou`** | `Final.Iu` | `"jou"` | `""` | **Misaki iou** | +| **`FinalIan_MapsToJEpsilonSplitN`** | `Final.Ian` | `"j\u025B"` | `"n"` | **jɛn 声調中間** | +| **`FinalIn_MapsToISplitN`** | `Final.In` | `"i"` | `"n"` | **j なし** | +| **`FinalIang_MapsToJaSplitNg`** | `Final.Iang` | `"ja"` | `"\u014B"` | **声調が中間** | +| **`FinalIng_MapsToISplitNg`** | `Final.Ing` | `"i"` | `"\u014B"` | **j なし、声調が中間** | +| **`FinalIong_MapsToJUpperUSplitNg`** | `Final.Iong` | `"j\u028A"` | `"\u014B"` | **jʊŋ** | +| `FinalU_MapsToUEmpty` | `Final.U` | `"u"` | `""` | 同一 | +| **`FinalUa_MapsToWa`** | `Final.Ua` | `"wa"` | `""` | **w 半母音** | +| **`FinalUo_MapsToWo`** | `Final.Uo` | `"wo"` | `""` | **w 半母音** | +| **`FinalUai_MapsToWai`** | `Final.Uai` | `"wai"` | `""` | **U+032F なし** | +| **`FinalUi_MapsToWei`** | `Final.Ui` | `"wei"` | `""` | **Misaki uei** | +| **`FinalUan_MapsToWaSplitN`** | `Final.Uan` | `"wa"` | `"n"` | **声調が中間** | +| **`FinalUn_MapsToWSchwaSplitN`** | `Final.Un` | `"w\u0259"` | `"n"` | **Misaki uen** | +| **`FinalUang_MapsToWaSplitNg`** | `Final.Uang` | `"wa"` | `"\u014B"` | **声調が中間** | +| **`FinalUeng_MapsToWSchwaSplitNg`** | `Final.Ueng` | `"w\u0259"` | `"\u014B"` | **声調が中間** | +| `FinalV_MapsToYEmpty` | `Final.V` | `"y"` | `""` | 同一 | +| **`FinalVe_MapsToTurnedHE`** | `Final.Ve` | `"\u0265e"` | `""` | **ɥe (U+0265)** | +| **`FinalVan_MapsToTurnedHEpsilonSplitN`** | `Final.Van` | `"\u0265\u025B"` | `"n"` | **ɥɛn (U+0265 + U+025B)** | +| **`FinalVn_MapsToYSplitN`** | `Final.Vn` | `"y"` | `"n"` | **声調が中間** | +| **`FinalEr_MapsToSchwaHookEmpty`** | `Final.Er` | `"\u025A"` | `""` | **ɚ (U+025A 単一)** | #### 声調テスト(5 件) @@ -263,46 +484,91 @@ internal static class PinyinToMisaki | **`ToneThird_MapsToDownArrow`** | `Tone.Third` (3) | `"\u2193"` | **↓** | | **`ToneFourth_MapsToSouthEastArrow`** | `Tone.Fourth` (4) | `"\u2198"` | **↘** | -#### テーブル網羅性テスト(3 件) +#### 特殊母音テスト(3 件) + +| テストケース | 入力 | 期待出力 | 検証ポイント | +|------------|------|---------|------------| +| **`RetroflexApical_MapsToBarredI`** | Zh/Ch/Sh/R + Final.I context | `"\u0268"` | **U+0268 直接** | +| **`AlveolarApical_MapsToBarredI`** | Z/C/S + Final.I context | `"\u0268"` | **U+0268 直接** | +| **`StandaloneO_MapsToOpenO`** | Initial.None + Final.O context | `"\u0254"` | **U+0254 ɔ** | + +#### Y/W 複合韻母テスト(23 件) + +セクション 3.1 の全 23 エントリに対する `(Prefix, Suffix, OmitInitial)` の検証。 + +| テストケース | 入力 | 期待出力 | +|------------|------|---------| +| **`Ya_MapsToJaNoOmit`** | `(Y, A)` | `("ja", "", false)` | +| **`Yan_MapsToJEpsilonSplitN`** | `(Y, An)` | `("j\u025B", "n", false)` | +| **`Yang_MapsToJaSplitNg`** | `(Y, Ang)` | `("ja", "\u014B", false)` | +| **`Yao_MapsToJau`** | `(Y, Ao)` | `("jau", "", false)` | +| **`Ye_MapsToJeNoOmit`** | `(Y, E)` | `("je", "", false)` | +| **`Yi_MapsToIWithOmit`** | `(Y, I)` | `("i", "", true)` | +| **`Yin_MapsToISplitNWithOmit`** | `(Y, In)` | `("i", "n", true)` | +| **`Ying_MapsToISplitNgWithOmit`** | `(Y, Ing)` | `("i", "\u014B", true)` | +| **`Yong_MapsToJUpperUSplitNg`** | `(Y, Ong)` | `("j\u028A", "\u014B", false)` | +| **`You_MapsToJou`** | `(Y, Ou)` | `("jou", "", false)` | +| **`Yu_MapsToYWithOmit`** | `(Y, V)` | `("y", "", true)` | +| **`Yue_MapsToTurnedHE`** | `(Y, Ve)` | `("\u0265e", "", false)` | +| **`Yuan_MapsToTurnedHEpsilonSplitN`** | `(Y, Van)` | `("\u0265\u025B", "n", false)` | +| **`Yun_MapsToYSplitNWithOmit`** | `(Y, Vn)` | `("y", "n", true)` | +| **`Wa_MapsToWaNoOmit`** | `(W, A)` | `("wa", "", false)` | +| **`Wai_MapsToWai`** | `(W, Ai)` | `("wai", "", false)` | +| **`Wan_MapsToWaSplitN`** | `(W, An)` | `("wa", "n", false)` | +| **`Wang_MapsToWaSplitNg`** | `(W, Ang)` | `("wa", "\u014B", false)` | +| **`Wei_MapsToWei`** | `(W, Ei)` | `("wei", "", false)` | +| **`Wen_MapsToWSchwaSplitN`** | `(W, En)` | `("w\u0259", "n", false)` | +| **`Weng_MapsToWSchwaSplitNg`** | `(W, Eng)` | `("w\u0259", "\u014B", false)` | +| **`Wo_MapsToWoNoOmit`** | `(W, O)` | `("wo", "", false)` | +| **`Wu_MapsToUWithOmit`** | `(W, U)` | `("u", "", true)` | + +#### テーブル網羅性テスト(5 件) | テストケース | 検証内容 | |------------|---------| -| `InitialTable_CoversAllEnumValues` | `Initial` enum の `None` 以外の全 22 値がテーブルのキーに存在する | -| `FinalTable_CoversAllEnumValues` | `Final` enum の `None` 以外の全 35 値がテーブルのキーに存在する | +| `InitialTable_CoversAllNonYWNonNoneEnumValues` | 声母テーブルが Y/W/None を除く 20 値を網羅する(全 22 - Y - W = 20) | +| `FinalTable_CoversAllNonNoneEnumValues` | 韻母テーブルが None を除く全 36 値を網羅する | | `ToneTable_HasCorrectLength` | 声調配列の長さが 5(Neutral=0 ~ Fourth=4) | +| `YWCompoundTable_Has23Entries` | Y/W 複合テーブルが厳密に 23 エントリであることを検証 | +| `NoEntryContainsU032F` | いずれのテーブルにも U+032F(非音節化符号)が含まれていないことを検証(Phase 1-R で全 strip 済み) | #### E2E テスト(本チケット外、T02 で実施) -T02 で Convert メソッド統合後に以下を検証する: - -- `"mā"` -> `"ma\u2192"` (第 1 声、矢印) -- `"jīn"` -> `"\u02A8in\u2192"` (j の合字 + 第 1 声) -- `"zài"` -> `"\u02A6ai\u032F\u2198"` (z の合字 + 非音節化 + 第 4 声) -- `"zhōng"` -> `"\u0288\u0282u\u032F\u014B\u2192"` (そり舌 + ong 非音節化 + 第 1 声) +T02 で `ConvertSyllable` 実装後に `.claude/tmp/misaki-gold.txt` の 137 件全件を通過することを検証する。 -## 5. 実装に関する懸念事項とレビュー項目 +## 6. 実装に関する懸念事項とレビュー項目 ### Unicode 文字の正確性 -1. **合字文字のコードポイント確認**: `ʨ` (U+02A8) と `ʦ` (U+02A6) は IPA Extensions ブロック (U+0250-U+02AF) に属する合字文字である。これらが正しく .NET の `char` / `string` で扱えることを確認すること(BMP 内のため問題ないはず)。 +1. **合字文字のコードポイント確認**: `ʨ` (U+02A8) と `ʦ` (U+02A6) は IPA Extensions ブロック (U+0250-U+02AF)、`ꭧ` (U+AB67) は Latin Extended-E ブロック (U+AB30-U+AB6F) に属する。これらが正しく .NET の `char` / `string` で BMP 内文字として扱えることを確認すること(全て BMP 内のため UTF-16 単一 code unit で表現可能)。 -2. **非音節化符号の結合文字性**: U+032F (COMBINING INVERTED BREVE BELOW) は結合文字であり、先行する基底文字に付与される。テーブル内で `"ai\u032F"` のように末尾に配置した場合、`i` に結合することを確認すること。 +2. **U+0265 ɥ、U+025A ɚ、U+0254 ɔ、U+0268 ɨ、U+028A ʊ、U+025B ɛ、U+0259 ə、U+0264 ɤ の正確性**: Phase 1-R で判明した追加の IPA 特殊文字。すべて Kokoro vocab に含まれる(Inv6 verified)ことを確認済み。 -3. **矢印記号のフォント互換性**: U+2192/U+2197/U+2193/U+2198 は Arrows ブロックに属し、IPA 文字と同列に出力される。Kokoro TTS が実際にこれらのコードポイントを期待しているかを Misaki の公式実装と照合すること。 +3. **矢印記号のフォント互換性**: U+2192/U+2197/U+2193/U+2198 は Arrows ブロックに属し、IPA 文字と同列に出力される。Misaki 公式実装と Kokoro vocab が実際にこれらのコードポイントを期待していることは Phase 1-R で verified。 -### 言語学的正確性 +4. **U+032F 非音節化符号の非使用**: 旧仕様では Ai/Ei/Ao/Ou/Iao/Iu/Uai/Ui/Ong/Iong の 10 韻母で U+032F を使用していたが、Phase 1-R で **Misaki 公式実装は U+032F を全く使わない** ことが判明。テーブル定義時に U+032F を含めないこと、および `NoEntryContainsU032F` テストで全テーブル検証すること。 -4. **ong の Misaki 表記**: 標準 IPA では `ʊŋ` だが、Misaki が `u̯ŋ` を採用する場合、音韻論的には非音節化 u を明示する表記となる。PinyinToIpa との意味的一貫性を確認すること。 +### 言語学的正確性(Phase 1-R 検証済み) -5. **iu/ui の展開形**: PinyinToIpa では `iu` を `ioʊ`、`ui` を `ueɪ` と展開しているが、Misaki でも同様に `iou̯` / `uei̯` と展開するかを確認すること。Misaki が `iu̯` / `ui̯` のような縮約形を使用している可能性がある。 +5. **Ong の音韻表記**: 標準 IPA / PinyinToIpa は `ʊŋ` (U+028A U+014B)、Misaki も同じ `ʊŋ` を使用する(旧仕様の `u̯ŋ` は Phase 1 の推測誤り)。 + +6. **Ian/Iong は半母音 j 開始**: Misaki は "ian" を `jɛn`、"iong" を `jʊŋ` として出力する(i ではなく j 開始)。対照的に Misaki "ie" は `je` であり、`jɛ` ではない(ここは標準 IPA の `iɛ` とも異なる特殊な Misaki 仕様)。 + +7. **Ve/Van は ɥ 開始**: Misaki は "üe" を `ɥe`、"üan" を `ɥɛn` として出力する(y ではなく U+0265 ɥ 開始)。 + +8. **Er は ɚ 単独**: Misaki は "er" を `ɚ` (U+025A) 単一記号で出力する(`əɻ` の 2 文字ではない)。 + +9. **retroflex/alveolar apical は共に ɨ**: Misaki は zh/ch/sh/r + i と z/c/s + i の両方で `ɨ` (U+0268) を直接使用する(`ɻ̩`/`ɹ̩` の結合音節主音記号付き 2 文字形式ではない)。 ### コード品質 -6. **既存変換クラスとの整合性**: `PinyinToIpa.cs` / `PinyinToPiperIpa.cs` と同一の `internal static class` パターン、同一のフィールド命名規則(`s_initialIpa`, `s_finalIpa`, `s_toneLetters`)に従うこと。ただし、フィールド名は `s_initialMisaki`, `s_finalMisaki`, `s_toneMisaki` 等に変更してもよい。 +10. **既存変換クラスとの整合性**: `PinyinToIpa.cs` / `PinyinToPiperIpa.cs` と同一の `internal static class` パターン、同一のフィールド命名規則(`s_*Misaki`)に従うこと。ただし韻母テーブルは Prefix/Suffix タプルのため、既存 2 クラスの `string` 単純マップとは構造が異なる。 + +11. **テーブルの不変性**: テーブルは `static readonly` で宣言し、実行時に変更されないことを保証すること。 -7. **テーブルの不変性**: テーブルは `static readonly` で宣言し、実行時に変更されないことを保証すること。 +12. **Y/W 複合韻母テーブルの存在理由の明示**: コードコメントで「Y/W は DotNetG2P PinyinParser の都合で声母にアサインされるが、Misaki では複合韻母として扱われる」ことを明記する。 -## 6. 一から作り直すとしたら +## 7. 一から作り直すとしたら ### 現行設計の評価 @@ -314,609 +580,135 @@ T02 で Convert メソッド統合後に以下を検証する: 一方で以下の課題がある: -- 22+35+5 = 62 エントリ x 4 形式 = 248 テーブルエントリの管理が必要(今後さらに増加) +- 22+36+5+23 = 86 エントリ × 4 形式 = 多数のテーブルエントリの管理が必要(今後さらに増加) - テーブル間の差異が暗黙的であり、どのエントリが異なるか一覧しにくい - 新しい Initial/Final が追加された場合、全変換クラスを更新する必要がある -### 代替設計案 - -#### A案: 差分テーブル方式 - -PinyinToIpa を基準テーブルとし、Misaki は差分のみ定義する。 +### Phase 1-R の教訓(最重要、Phase 1 を一から作り直すとしたら何を変えるか) -```csharp -internal static class PinyinToMisaki -{ - // PinyinToIpa のテーブルを基準に、差異のあるエントリのみ上書き - private static readonly Dictionary s_initialOverrides = new() - { - [Initial.J] = "\u02A8", // tɕ -> ʨ - [Initial.Q] = "\u02A8\u02B0", // tɕʰ -> ʨʰ - [Initial.Z] = "\u02A6", // ts -> ʦ - [Initial.C] = "\u02A6\u02B0", // tsʰ -> ʦʰ - }; +本セクションは Phase 1 で策定した旧 T01/T02 の設計が 12 項目にわたって誤っており、Phase 1-R で全面改訂となった経験を踏まえ、後続プロジェクトへの教訓として残すもの。 - internal static string GetInitial(Initial i) => - s_initialOverrides.TryGetValue(i, out var v) ? v : PinyinToIpa.GetInitial(i); -} -``` +#### 教訓 1: 公式実装を必ず fetch して実測してから設計する -利点: 差異が明示的、メンテナンスコストが低い。欠点: PinyinToIpa への依存が発生し、PinyinToIpa の変更が Misaki に波及する。 +**Phase 1 の失敗**: Misaki の独自表記(合字・矢印声調・非音節化符号)を Kokoro リポジトリの README / Python パッケージ概要・公開サンプル出力から「推測」して設計した。結果: -#### B案: 型安全 enum + 属性方式 +- `J` → `ʨ` は偶然正解だったが、`Zh` → `ʈʂ`(誤)/ 正 `ꭧ` U+AB67 を見逃した +- 非音節化符号 U+032F の大量使用(誤)を仕様に組み込んでしまった +- 声調位置を「末尾付加」(誤)と設計したが、実際は「韻母中間挿入」だった +- Retroflex apical を `ɻ̩`(誤)と想定したが、実際は `ɨ` 単一文字だった -各音素 enum 値に属性で全形式のマッピングを定義する。 +**Phase 1-R での対応**: -```csharp -public enum Initial : byte -{ - [IpaMapping("tɕ")] - [MisakiMapping("ʨ")] - [PiperMapping("tɕ")] - J, - // ... -} +```bash +# 公式実装を fetch +gh api repos/hexgrad/misaki/contents/misaki/zh.py +gh api repos/hexgrad/misaki/contents/misaki/transcription.py +# uv で実環境を構築 +uv init misaki-verify +uv add misaki +# 137 件のテストケースを実測 +uv run python -c "import misaki.zh; g = misaki.zh.ZHG2P(); print(g('ma1'))" +# → 実際の出力から逆算してマッピングを再構成 ``` -利点: 音素と全マッピングが一箇所に集約される。欠点: 属性ベースはリフレクション依存でパフォーマンスに影響、.NET Standard 2.1 でのソース生成器非対応、既存設計との大幅な乖離。 +**後続プロジェクトへのアクション**: -#### C案: TSV/CSV 駆動 +- Misaki/piper-plus/Kokoro/Flite 等の他言語 G2P 実装を参考にする場合、**必ず公式 GitHub リポジトリから実装ファイルを fetch し、uv / pip / docker 等で実行環境を作って実測する** +- 実測できない場合は「推測」を明記し、T02 での実測検証を必須タスクとする +- 最小 50 件程度の gold standard(`.claude/tmp/-gold.txt`)を作成し、T01 の時点からテストに組み込む -マッピングを外部 TSV ファイルとして管理し、起動時にロードする。 +#### 教訓 2: 声調位置・韻母構造を最初から設計に入れる -```tsv -Initial IPA Misaki Piper Zhuyin -J tɕ ʨ tɕ ㄐ -Q tɕʰ ʨʰ tɕʰ ㄑ -``` +**Phase 1 の失敗**: 韻母テーブルを `Dictionary`(単純な文字列マップ)として設計した。結果: -利点: マッピングの一覧性が最高、非プログラマでも編集可能。欠点: 起動時パースコスト、Unicode 文字の TSV 内表現が不安定(エディタ依存)、embedded resource 管理の複雑化。 +- `man1` を `mAn→`(末尾声調)と想定したが、実際は `ma→n`(中間声調) +- 16 韻母(An/En/Ang/Eng/Ong/In/Ing/Ian/Iang/Iong/Uan/Un/Uang/Ueng/Van/Vn)で全面修正が必要となった -### 推奨 +**Phase 1-R での対応**: -**現行の独立テーブルパターン(コピー&修正方式)を継続する。** 理由: +韻母テーブルを `Dictionary`(Prefix + Tone + Suffix 方式)に変更。`ConvertSyllable` は `prefix + toneArrow + suffix` の順に結合する。 -1. 変換形式は 4 種類にとどまり、管理負荷は許容範囲内 -2. 既存の 3 クラスとの一貫性を維持できる -3. 各テーブルが自己完結しており、バグの局所化が容易 -4. ランタイムのパフォーマンスオーバーヘッドがゼロ +**後続プロジェクトへのアクション**: -ただし、本チケットのテーブル設計時に差異を明確にドキュメント化し(本チケット自体がその役割を果たす)、将来的にマッピング形式が 6 種以上に増えた場合は A 案(差分テーブル方式)への移行を検討する。 - -### マッピング戦略の再検討(追加レビュー) - -本セクションは、マッピング戦略の観点から現行の「独立テーブル×4」方式を再検討し、より保守性の高い代替案を具体化するためのものである。観点は以下の 4 つ: - -1. TSV 外部ファイル化(スキーマ・ロード方式・埋め込みリソース化) -2. 差分マッピング方式(BaseIpaTable + DialectDiff)の具体例 -3. Source Generator によるコンパイル時マッピング生成 -4. 各方式のパフォーマンス・保守性・可読性の比較 - -#### D案: TSV 外部ファイル化の具体案 - -##### スキーマ設計 - -`src/DotNetG2P.Chinese/Data/pinyin_mapping.master.tsv` を単一のマスターファイルとして配置し、全変換形式のエントリを横並びで保持する。 - -```tsv -# pinyin_mapping.master.tsv -# type: initial | final | tone -# key: enum 名(Initial.J, Final.Ai, Tone.First 等) -# ipa: PinyinToIpa の値 -# misaki: PinyinToMisaki の値 -# piper: PinyinToPiperIpa の値 -# zhuyin: PinyinToZhuyin の値 -# comment: Unicode コードポイント・音韻的コメント -type key ipa misaki piper zhuyin comment -initial B p p p ㄅ U+0070 -initial J tɕ ʨ tɕ ㄐ Misaki: U+02A8 (tc ligature) -initial Z ts ʦ ts ㄗ Misaki: U+02A6 (ts ligature) -final Ai aɪ ai̯ aɪ ㄞ Misaki: i + U+032F -final Ao aʊ au̯ aʊ ㄠ Misaki: u + U+032F -final Ong ʊŋ u̯ŋ ʊŋ ㄨㄥ Misaki: u + U+032F + ŋ -tone First ˥˥ → ˥˥ ˉ Misaki: U+2192 -tone Second ˧˥ ↗ ˧˥ ˊ Misaki: U+2197 -``` +- 韻母 / 音節 / スタック構造を扱う言語では、声調位置・ストレス位置・アクセント核位置を「どこに挿入するか」を最初から設計に組み込む +- 単純な `string` マップは後から構造変更しにくい → 最初から `(Prefix, Suffix)` タプルまたは `record PhonemeTemplate(string Prefix, string Suffix, int ToneInsertPosition)` で構造化する +- 「末尾に付加」という仮定を最初から疑う -**スキーマ設計ポイント:** +#### 教訓 3: Y/W は声母ではなく複合韻母として扱うのが Misaki 公式の設計思想 -- **1 行 1 エントリ**: type/key を複合キーとして一意に特定 -- **空セル許容**: zhuyin など一部形式でエントリが存在しない場合は空セル -- **コメント列**: Unicode コードポイント・音韻的メモを保持(レビュー時の視認性向上) -- **UTF-8 BOM なし**: エディタ依存を減らすため BOM なし UTF-8 で統一 -- **`#` 行コメント**: ファイル冒頭で型定義を説明 +**Phase 1 の失敗**: DotNetG2P の `PinyinParser` が "wang" を `Initial.W + Final.Ang` に parse するため、`W` を `w` 1 文字、`Ang` を `aŋ` とマップし、連結すれば `waŋ→` になると想定した。実際には Misaki は: -##### ロード方式 +- "wa" → `wa→`、"wai" → `wai→`、"wang" → `wa→ŋ`(`w` が複合韻母の一部) +- "yu" → `y→`(`ɥy→` ではなく、ɥ は省略される) +- "yi" → `i→`(`ji→` ではなく、j は省略される) +- "wu" → `u→`(`wu→` ではなく、w は省略される) -```csharp -internal static class PinyinMappingTable -{ - // 起動時に 1 度だけロード(lazy initialization) - private static readonly Lazy s_data = new(LoadFromResource); +単純な声母マップ方式では Initial 省略ルールが表現できない。 - private static MappingData LoadFromResource() - { - var asm = typeof(PinyinMappingTable).Assembly; - using var stream = asm.GetManifestResourceStream( - "DotNetG2P.Chinese.Data.pinyin_mapping.master.tsv"); - using var reader = new StreamReader(stream, Encoding.UTF8); - return TsvParser.Parse(reader); - } - - internal static string GetInitial(Initial i, MappingFormat fmt) - => s_data.Value.Initials[(i, fmt)]; -} -``` +**Phase 1-R での対応**: -**埋め込みリソース化:** +`s_yWCompoundMisaki : Dictionary<(Initial, Final), (Prefix, Suffix, OmitInitial)>` という **23 エントリの専用テーブル** を追加。`ConvertSyllable` は Y/W 声母の場合このテーブルを最優先で参照し、Initial を省略するかどうかを `OmitInitial` フラグで判定する。 -```xml - - - - -``` +**後続プロジェクトへのアクション**: -他言語パッケージ(Spanish/French/Portuguese)で採用済みの例外辞書 TSV と同一の埋め込み方式を採用することで、プロジェクト全体の一貫性が保たれる。 +- Pinyin parser の内部構造(Initial + Final)と、目的の音素表記系(中国語音韻論)の構造が一致しない場合は、**複合韻母層を個別のルックアップテーブルとして用意する** +- 「Y/W を声母として 1 文字マップする」という設計パターンは DotNetG2P.Chinese の PinyinToIpa / PinyinToPiperIpa / PinyinToZhuyin 全てで採用されているが、音韻論的には正しくない(Y/W は中国語音韻論では半母音として Medial に属し、声母ではない)。将来的に PinyinParser を再設計する場合は Medial 層を独立させることを検討する -**利点:** +#### 教訓 4: ligature (U+02A8/U+02A6) と U+AB67 は Kokoro vocab 互換のため必須採用 -- マッピング一覧性が最高(全形式を横並びで確認可能) -- 差分が視覚的に明確(差異のあるセルが目立つ) -- 非プログラマ(言語学者・翻訳者)でも編集可能 -- 新形式追加時は列を 1 つ追加するだけ +**Phase 1 の失敗**: Phase 1 では `J` → `ʨ` (U+02A8) を採用したが、内部では「見た目上 tɕ と同等なのでどちらでも良い」と判断していた。同様に `Zh` → `ʈʂ` (U+0288 U+0282) を「合字にしても実質同じ」と想定していた。 -**欠点:** +**Phase 1-R で判明した事実**: -- 起動時パースコスト(ただし Lazy + 62 エントリで実測 < 1ms) -- TSV 内の Unicode 結合文字(U+032F)はエディタで不可視になりがち → コメント列で補う必要 -- コンパイル時型安全性の喪失(enum 名の typo がランタイムエラーに) +- **Kokoro 82M の vocab ファイルには U+02A8 / U+02A6 / U+AB67 が単一トークンとして含まれる**(Inv6 verified) +- **`tɕ` (U+0074 U+0255) や `ʈʂ` (U+0288 U+0282) は Kokoro vocab に含まれない** +- **非 vocab 文字を TTS に入力すると UNK トークン化され、音声品質が著しく劣化する** -#### E案: 差分マッピング方式(BaseIpaTable + DialectDiff)の具体例 +したがって、Kokoro 互換性を保証するためには **必ず合字を使う必要がある**。 -A 案をさらに具体化し、BaseTable/DiffTable 構造を正規化する。 +**後続プロジェクトへのアクション**: -```csharp -// 基底テーブル(標準 IPA、すべての形式の起点) -internal static class BaseIpaTable -{ - internal static readonly IReadOnlyDictionary Initials = new Dictionary - { - [Initial.B] = "p", - [Initial.J] = "t\u0255", // tɕ - [Initial.Z] = "ts", - // ... 全 22 エントリ - }; - - internal static readonly IReadOnlyDictionary Finals = new Dictionary - { - [Initial.Ai] = "a\u026A", // aɪ - // ... 全 35 エントリ - }; -} - -// 差分(Misaki 方言) -internal static class MisakiDiff -{ - internal static readonly IReadOnlyDictionary InitialOverrides = new Dictionary - { - [Initial.J] = "\u02A8", // ʨ - [Initial.Q] = "\u02A8\u02B0", - [Initial.Z] = "\u02A6", // ʦ - [Initial.C] = "\u02A6\u02B0", - }; +- TTS vocab 互換性を前提とする G2P 実装では、**出力対象の TTS の vocab ファイル(tokenizer.json / phoneme_set.txt 等)を最初から取得し、マッピングに含まれる全文字が vocab に存在することを検証する** +- DotNetG2P.Chinese では Kokoro vocab との照合を CI で自動化することを将来的に検討(現状は Phase 1-R の人手検証) +- ligature(結合済み単一文字)vs 2 文字並置の違いは視覚的に判別しづらいため、**必ず Unicode コードポイントで明記する**(`ʨ` ではなく `U+02A8`) - internal static readonly IReadOnlyDictionary FinalOverrides = new Dictionary - { - [Final.Ai] = "ai\u032F", - [Final.Ei] = "ei\u032F", - [Final.Ao] = "au\u032F", - [Final.Ou] = "ou\u032F", - [Final.Ong] = "u\u032F\u014B", - [Final.Iao] = "iau\u032F", - [Final.Iu] = "iou\u032F", - [Final.Iong] = "iu\u032F\u014B", - [Final.Uai] = "uai\u032F", - [Final.Ui] = "uei\u032F", - }; - - internal static readonly string[] ToneOverrides = new[] { "", "\u2192", "\u2197", "\u2193", "\u2198" }; -} +#### 教訓 5: gold standard を T01 の時点でテストに組み込む -// ルックアップ(差分優先、なければ Base) -internal static class PinyinToMisaki -{ - internal static string GetInitial(Initial i) - => MisakiDiff.InitialOverrides.TryGetValue(i, out var v) ? v : BaseIpaTable.Initials[i]; - - internal static string GetFinal(Final f) - => MisakiDiff.FinalOverrides.TryGetValue(f, out var v) ? v : BaseIpaTable.Finals[f]; - - internal static string GetTone(int t) => MisakiDiff.ToneOverrides[t]; -} -``` - -**起動時マージによる事前計算(パフォーマンス最適化):** - -```csharp -private static readonly IReadOnlyDictionary s_initialMerged = MergeBaseAndDiff( - BaseIpaTable.Initials, MisakiDiff.InitialOverrides); - -private static Dictionary MergeBaseAndDiff( - IReadOnlyDictionary @base, - IReadOnlyDictionary diff) -{ - var result = new Dictionary(@base); - foreach (var kvp in diff) result[kvp.Key] = kvp.Value; - return result; -} -``` +**Phase 1 の失敗**: Phase 1 では「テーブル定義の T01 ではテーブル単体のユニットテストのみ、E2E テストは T02 で実施」という方針だった。結果、T02 実装時に初めて仕様誤りに気づいた。 -この事前マージ方式により、ランタイムのルックアップは基底テーブルと同等のコスト(`TryGetValue` 1 回)となる。 +**Phase 1-R での対応**: -**利点:** +`.claude/tmp/misaki-gold.txt` に 137 件の `(pinyin → misaki IPA)` ペアを実測で取得し、T01 の時点で全テーブルエントリが gold standard と整合するかを「卓上検証」(エントリを手動で組み合わせて gold 出力と比較)する。 -- Misaki の差異が 15 エントリ(initial 4 + final 10 + tone 4、軽声除く)のみに集約され、視認性が最高 -- Base の変更が全方言に自動波及(意図した一貫性) -- テストで `Diff.Count` を検証することで「想定外の差分」を検出可能 -- ランタイムオーバーヘッドなし(事前マージ時) +**後続プロジェクトへのアクション**: -**欠点:** +- T01(テーブル定義)の段階で gold standard に対する卓上検証を必須化する +- 137 件の gold standard を直接テストにするのは T02 スコープだが、T01 ではサンプル 20 件程度をテーブル組み合わせテスト(`Prefix + ToneArrow + Suffix` 結合)として実装する +- gold standard は repository 外(`.claude/tmp/`)ではなく、`tests/DotNetG2P.Tests/ChineseG2P/Fixtures/misaki-gold.txt` に埋め込みリソースとして配置することを T02 で検討する -- 方言間の予期せぬ結合(Base 変更の波及)が時に問題になる -- Zhuyin のように全エントリが Base と異なる形式(ラテン→漢字由来記号)では差分方式の利点が消失 -- 「どの値が Base 由来か Diff 由来か」の区別が API 越しには見えない +### 代替設計案(参考、Phase 1 からの継続) -#### F案: Source Generator によるコンパイル時マッピング生成 - -C# Source Generator を用いて、TSV ファイルをコンパイル時に読み込み、強型付けされた `static readonly` フィールドを自動生成する。 - -##### 設計 - -```csharp -// Generators/PinyinMappingGenerator.cs -[Generator] -public class PinyinMappingGenerator : IIncrementalGenerator -{ - public void Initialize(IncrementalGeneratorInitializationContext context) - { - // AdditionalFiles から TSV を取得 - var tsvFiles = context.AdditionalTextsProvider - .Where(f => f.Path.EndsWith("pinyin_mapping.master.tsv")); - - context.RegisterSourceOutput(tsvFiles, (spc, file) => - { - var content = file.GetText()?.ToString(); - var entries = ParseTsv(content); - var source = GenerateCode(entries); - spc.AddSource("PinyinMappingTable.g.cs", source); - }); - } -} -``` - -##### 生成される出力例 - -```csharp -// PinyinMappingTable.g.cs (auto-generated) -namespace DotNetG2P.Chinese.Conversion; - -internal static class PinyinToMisakiGenerated -{ - internal static readonly Dictionary Initials = new() - { - [Initial.B] = "p", - [Initial.J] = "\u02A8", - [Initial.Z] = "\u02A6", - // ... 全エントリがコンパイル時に埋め込まれる - }; -} -``` - -##### プロジェクト設定 - -```xml - - - - -``` - -**利点:** - -- TSV の編集容易性と、コンパイル時生成によるランタイム高速性を両立 -- enum 名の typo がコンパイルエラーとして検出される(ジェネレータ側でチェック実装可能) -- 起動時パースコストゼロ(コード生成済み) -- IDE での F12 でジェネレート済みコードへ跳べる(デバッグ容易) -- Base/Diff 関係をジェネレータ内で計算し、最終形式を生成可能 - -**欠点:** - -- **.NET Standard 2.1 ターゲットとの互換性問題**: Source Generator は `netstandard2.0` ターゲットの Generator プロジェクトが必要。Unity IL2CPP ビルドとの相性も要検証 -- ジェネレータプロジェクトの追加によるビルド複雑化 -- デバッグ時の可読性低下(生成コードが見慣れた形と異なる場合) -- 既存の他言語パッケージ(TSV を Runtime ロードしている)との一貫性が崩れる -- Roslyn API の学習コスト(特に Incremental Generator) - -**Unity 互換性の懸念:** - -Unity 2021.2+ の Roslyn バージョンで Incremental Generator が動作するかは要検証。Unity パッケージ側では UPM 経由で配布するため、Generator を同梱しない「ビルド済みコード+TSV リソース」方式のハイブリッドも検討すべき。 - -#### 各方式のパフォーマンス・保守性・可読性比較 - -| 方式 | ランタイム性能 | 起動時コスト | 保守性 | 可読性 | 型安全性 | Unity 互換 | -|------|--------------|------------|--------|--------|---------|-----------| -| **現行(独立テーブル)** | ★★★ (最速) | 無 | ★ (4 箇所同期) | ★ (差分不明瞭) | ★★★ | ★★★ | -| **A案: 差分テーブル(遅延)** | ★★ (TryGet 2 回) | 無 | ★★★ | ★★★ | ★★★ | ★★★ | -| **E案: 差分テーブル(事前マージ)** | ★★★ | 微小 (< 1ms) | ★★★ | ★★★ | ★★★ | ★★★ | -| **B案: 属性方式** | ★ (リフレクション) | 中 (初回のみキャッシュ) | ★★ | ★★ | ★★★ | ★★ (IL2CPP strip 注意) | -| **C/D案: TSV ロード** | ★★★ (ロード後は Dict 参照) | 小 (数ms) | ★★★ | ★★★★ (全形式横並び) | ★ (ランタイム検証) | ★★★ (Embedded Resource) | -| **F案: Source Generator** | ★★★ (最速) | 無 | ★★★ | ★★★ | ★★★ | ★ (要検証) | - -**評価軸の詳細:** - -- **ランタイム性能**: 1 回のルックアップコスト。DictionaryTryGetValue は O(1) なので実際の差は微小だが、TSV 方式は起動後は埋め込み方式と同等 -- **保守性**: マッピング追加・修正時の影響範囲。差分方式と TSV 方式が最良 -- **可読性**: 全形式の差分を一覧する際の容易さ。TSV > 差分 > 独立 -- **型安全性**: enum 名の typo がコンパイル時に検出されるか -- **Unity 互換**: IL2CPP/AOT/Embedded Resource 制約への適合度 - -#### マッピング戦略の推奨(追加レビューの結論) - -**短期(T01/T02 本チケット): 現行の独立テーブル方式を維持する。** 理由は既存の「### 推奨」セクションに記載の通り。ただし以下の追加措置を推奨: - -1. **Unicode 定数クラスの導入**: `Internal/ChineseUnicode.cs` に `NonSyllabicMark = "\u032F"`, `TcLigature = "\u02A8"` 等の名前付き定数を定義し、テーブル定義時に使用する。エスケープシーケンスの散在を防ぎ、レビュー時の誤読を削減する。 - - ```csharp - internal static class ChineseUnicode - { - internal const string NonSyllabicMark = "\u032F"; // COMBINING INVERTED BREVE BELOW - internal const string TcLigature = "\u02A8"; // ʨ - internal const string TsLigature = "\u02A6"; // ʦ - internal const string Aspirated = "\u02B0"; // ʰ - internal const string RightArrow = "\u2192"; // → - // ... - } - ``` - -2. **差分検証テストの追加**: `PinyinToIpa` と `PinyinToMisaki` のテーブルを比較し、差異エントリ数が想定値(initial 4 + final 10 + tone 4 = 18)と一致することを検証するメタテストを追加。想定外の差分を早期検出する。 - -3. **マスター TSV ドキュメント化**: T01 本チケットの表を元に、`docs/chinese/pinyin_mapping_reference.md` として全形式の横並び表を作成・維持する。コードとは独立したドキュメントとし、コード変更時の同期は CI で差分チェックする(目視レビュー)。 - -**中期(5 形式目追加時・例: Kaldi lexicon / LEX 形式等): E 案(差分テーブル・事前マージ)へ移行する。** 理由: - -- 形式数が 5 以上になると独立テーブルの同期コストが許容範囲を超える -- 事前マージ方式ならランタイム性能の劣化なし -- Unity 互換性の懸念なし(通常のコードのみ) -- TSV 方式ほど大掛かりな変更ではなく、段階的移行が容易 - -**長期(10 形式以上・例: 複数 TTS エンジン対応): D 案(TSV 外部ファイル)へ移行する。** 理由: - -- 非プログラマによる編集が可能になり、言語学者の貢献を受け入れやすい -- 他言語パッケージ(Es/Fr/Pt)と一貫した方式となる -- Source Generator(F 案)は Unity 互換性の懸念があるため、ランタイムロード方式(Lazy 初期化)を推奨 - -**Source Generator(F 案)は現時点では採用非推奨。** .NET Standard 2.1 / Unity IL2CPP 環境との互換性検証コストが高く、T01/T02 のスコープを大幅に超える。将来的に Unity が Roslyn Incremental Generator を正式サポートした時点で再検討する。 - -### アーキテクトレビュー(統合的まとめ) - -上記「現行設計の評価」「代替設計案(A/B/C 案)」「推奨」および「マッピング戦略の再検討(D/E/F 案)」は網羅的だが、**ディクショナリ定義の物理構造**にしか注目していない。本節では、「`PinyinToZhuyin` を含めた 4 クラス全体の本質的な構造差」と、「既存レビューで触れられていない C# 言語機能の活用余地」の観点からレビューを補完する。 - -#### 4 クラスの構造分類 — 対称な抽象化の限界 - -現行 4 クラス(追加予定の Misaki 含む)の構造を分類すると、見かけ上「4 つのコピペ」に見える状況は、実は **2 つの異なるパターン** に分離できる。 - -| クラス | キー型 | マッピング構造 | 抽象化対象 | -|-------|-------|--------------|----------| -| `PinyinToIpa` | `Initial`/`Final` enum | 声母/韻母/声調の 3 テーブル | **IPA ファミリ** | -| `PinyinToPiperIpa` | `Initial`/`Final` enum | 声母/韻母の 2 テーブル(声調なし) | **IPA ファミリ** | -| `PinyinToMisaki` (予定) | `Initial`/`Final` enum | 声母/韻母/声調(矢印) | **IPA ファミリ** | -| `PinyinToZhuyin` | `string` (pinyin) | `string`→注音符号 | **文字列変換ファミリ**(別系統) | - -**重要な示唆:** `PinyinToZhuyin` は `Dictionary` でピンイン文字列を直接変換しており、`Initial`/`Final` enum を経由しない。これは音韻論的にも正当で(注音符号は中国語固有の表記で IPA 的な音素分解が不要)、構造的に IPA ファミリとの統一は不自然である。 - -したがって、**抽象化の対象は「IPA ファミリ 3 クラス」に限定すべき**であり、Zhuyin を巻き込む共通化は設計目標として適切ではない。本セクション以降の「共通基盤」は IPA ファミリの話に限る。 - -#### C# 言語機能ベース 4 方式の比較 - -本チケット既存セクション(A/B/C/D/E/F 案)は **テーブル配置戦略** に焦点を当てていたが、ここでは **共通処理の抽象化メカニズム** として C# が提供する 4 つのアプローチを比較する。T02 と共通する論点だが、T01 の視点では「マッピングテーブルをどう型として表現するか」が主題となる。 - -##### 方式 1: interface ベース - -```csharp -internal interface IPinyinMapping -{ - string GetInitial(Initial initial); - string GetFinal(Final final_); - string GetTone(Tone tone); - string RetroflexApical { get; } - string AlveolarApical { get; } -} - -internal sealed class MisakiMapping : IPinyinMapping -{ - private static readonly Dictionary s_initials = /* ... */; - public string GetInitial(Initial i) => s_initials[i]; - // ... -} -``` - -**利点:** モック化が自然(テスト時にフェイク実装注入可能)、依存反転原則(DIP)に忠実、将来の DI 導入と整合。 -**欠点:** インスタンスメソッド呼び出しが仮想メソッドディスパッチになる(JIT 最適化で軽減されるが hot path では計測差が出る)。既存 `internal static` クラス群との様式不整合。 - -##### 方式 2: abstract class ベース - -```csharp -internal abstract class PinyinMappingBase -{ - protected abstract IReadOnlyDictionary Initials { get; } - protected abstract IReadOnlyDictionary Finals { get; } - protected abstract string[] ToneMarkers { get; } - - // 共通ロジックを基底クラスに集約(テンプレートメソッド) - public virtual string Convert(PinyinSyllable s, bool includeTones) - { - // 全クラス共通の変換フロー - } - - // 差異を許容する拡張ポイント - protected virtual string HandleRetroflexApical() => "\u027B\u0329"; - protected virtual string HandleAlveolarApical() => "\u0279\u0329"; -} - -internal sealed class MisakiMapping : PinyinMappingBase -{ - protected override IReadOnlyDictionary Initials => s_initials; - // ... -} -``` - -**利点:** 共通ロジックを基底に集約できる(interface だけでは default interface methods を使わない限り不可能、default interface methods は .NET Standard 2.1 で限定的)。オーバーライドによる柔軟な差分実装が可能。 -**欠点:** 単一継承の制約、`sealed class` でない限りさらなる派生を招きやすい、interface より結合度が高い。 - -##### 方式 3: record + switch 式ベース(推奨候補) - -```csharp -// マッピングを不変の値オブジェクトとして表現 -internal sealed record PinyinMappingTable( - IReadOnlyDictionary Initials, - IReadOnlyDictionary Finals, - IReadOnlyList ToneMarkers, - string RetroflexApical, - string AlveolarApical, - bool IncludeTonesByDefault); - -internal static class PinyinMappingTables -{ - public static readonly PinyinMappingTable Ipa = new( - Initials: BuildIpaInitials(), - Finals: BuildIpaFinals(), - ToneMarkers: new[] { "", "\u02E5\u02E5", "\u02E7\u02E5", "\u02E8\u02E9\u02E6", "\u02E5\u02E9" }, - RetroflexApical: "\u027B\u0329", - AlveolarApical: "\u0279\u0329", - IncludeTonesByDefault: true); - - public static readonly PinyinMappingTable Misaki = Ipa with - { - Initials = new Dictionary(Ipa.Initials) - { - [Initial.J] = "\u02A8", - [Initial.Q] = "\u02A8\u02B0", - [Initial.Z] = "\u02A6", - [Initial.C] = "\u02A6\u02B0", - }, - Finals = new Dictionary(Ipa.Finals) - { - [Final.Ai] = "ai\u032F", - [Final.Ei] = "ei\u032F", - [Final.Ao] = "au\u032F", - [Final.Ou] = "ou\u032F", - [Final.Ong] = "u\u032F\u014B", - [Final.Iao] = "iau\u032F", - [Final.Iu] = "iou\u032F", - [Final.Iong] = "iu\u032F\u014B", - [Final.Uai] = "uai\u032F", - [Final.Ui] = "uei\u032F", - }, - ToneMarkers = new[] { "", "\u2192", "\u2197", "\u2193", "\u2198" }, - }; -} - -// 変換エンジンは record を受け取る純粋関数群 -internal static class PinyinConversionEngine -{ - public static string Convert(PinyinSyllable syllable, PinyinMappingTable table, bool includeTones) - { - // 中央集権的な変換ロジック - var sb = new StringBuilder(16); - - if (syllable.Initial != Initial.None) - { - var skipSemivowel = - (syllable.Initial == Initial.Y || syllable.Initial == Initial.W) - && ShouldOmitSemivowel(syllable.Initial, syllable.Final); - - if (!skipSemivowel) - sb.Append(table.Initials[syllable.Initial]); - } - - if (syllable.Final != Final.None) - { - // そり舌/歯茎母音の分岐も record の値を参照 - var finalStr = (syllable.Final, syllable.Initial) switch - { - (Final.I, var i) when IsRetroflex(i) => table.RetroflexApical, - (Final.I, var i) when IsAlveolar(i) => table.AlveolarApical, - _ => table.Finals[syllable.Final], - }; - sb.Append(finalStr); - } - - if (includeTones && syllable.Tone != Tone.Neutral) - sb.Append(table.ToneMarkers[(int)syllable.Tone]); - - return sb.ToString(); - } -} - -// 既存の内部 API は record を渡す薄いラッパーとして維持(公開 API の互換性保証) -internal static class PinyinToMisaki -{ - public static string Convert(string pinyin) => Convert(pinyin, true); - - public static string Convert(string pinyin, bool includeTones) - { - if (string.IsNullOrEmpty(pinyin)) return string.Empty; - string normalized = ToneConverter.ToToneMarked(pinyin); - if (!PinyinParser.TryParse(normalized, out var syllable)) return string.Empty; - return PinyinConversionEngine.Convert(syllable, PinyinMappingTables.Misaki, includeTones); - } -} -``` - -**利点:** -- **`record` の値セマンティクスで差分記述が自然**: `Ipa with { ... }` 構文で親テーブルからの差分のみを表現できる(既存 A 案「差分テーブル」の型安全版) -- **C# 8.0+ switch 式**でそり舌/歯茎分岐が簡潔に記述でき、分岐の網羅性チェックをコンパイラが行う -- **.NET Standard 2.1 互換**: `record` は C# 9.0 の機能だが、`LangVersion` 指定で .NET Standard 2.1 でも使用可能(DotNetG2P.Chinese の現状設定を確認すること) -- **純粋関数**として `PinyinConversionEngine.Convert` を実装でき、テストが容易(副作用なし、内部状態なし) -- **既存の公開 API 互換性を維持**: `PinyinToIpa.Convert(...)` 等のファサードは薄いラッパーとして残せる -- **テーブル網羅性テストを 1 箇所で書ける**: `PinyinMappingTables.Ipa`, `Misaki`, `PiperIpa` を `IEnumerable` で列挙し、メタテストで全テーブルを同一基準で検証 - -**欠点:** -- `record` の `with` 式は浅いコピーのため、Dictionary の中身を深くコピーする必要がある(上記コードでは明示的に `new Dictionary<...>(Ipa.Initials) { ... }` と書いている) -- `IReadOnlyDictionary` プロパティへのアクセスは仮想呼び出しになるため、`Dictionary<>` 直接参照より数 ns 遅い(実測では意味のない差) - -##### 方式 4: source generator ベース +#### A案: 差分テーブル方式 -既存 F 案で詳述済みのため簡略化するが、T01 視点では「TSV やコード片から `PinyinMappingTable` record を自動生成する」という統合的な使い方が有望。ただし、Unity/IL2CPP 互換性の懸念があるため短期的な採用は見送り。 +PinyinToIpa を基準テーブルとし、Misaki は差分のみ定義する。ただし Phase 1-R で判明したように **Misaki 差異は 6 + 16 + 4 + 23 + 3 = 52 箇所に及ぶ** ため、差分方式の利点(差異の局所化)は希薄化する。 -#### 4 方式の比較表(T01 視点) +#### E案: 事前マージ方式 -| 観点 | 方式 1: interface | 方式 2: abstract class | **方式 3: record+switch** | 方式 4: generator | -|------|----------------|---------------------|-------------------------|-----------------| -| 既存 `internal static` 様式との整合 | 低 | 低 | **中(ファサード維持)** | 高 | -| 差分オーバーライド構文 | 手動 (override) | 手動 (override) | **`with` 式で自然** | コード生成 | -| コンパイル時型安全性 | 中 | 中 | **高 (switch網羅性)** | 高 | -| 既存公開 API 互換 | 要書換 | 要書換 | **維持可** | 維持可 | -| テスト容易性 | 高 (モック) | 中 | **高 (純粋関数)** | 中 | -| ランタイムコスト | 中 (仮想呼び出し) | 中 | **低 (record は class)** | 最低 | -| Unity/IL2CPP 互換 | 高 | 高 | **高** | 要検証 | -| .NET Standard 2.1 互換 | 高 | 高 | **高 (LangVersion 要設定)** | 要検証 | -| リファクタ規模 | 大 | 大 | **中** | 大 | +BaseIpaTable と MisakiDiff を起動時にマージする方式。Phase 1-R での差異数増加により、現行の独立テーブル方式と実用上の差はない。 -#### T01 のマッピング設計への実践的推奨 +#### 推奨(Phase 1-R 後の再評価) -上記の分析を踏まえ、T01 で確定すべきマッピング定義の「書き方」として以下を推奨する。 +**現行の独立テーブルパターン(コピー&修正方式)を継続する。** 理由: -**推奨 1: 現行の独立テーブル方式を踏襲しつつ、将来の `record` 化を見据えた「機械的移行可能」な書式に揃える** +1. 変換形式は 4 種類(IPA / Piper IPA / Misaki / Zhuyin)にとどまり、管理負荷は許容範囲内 +2. 既存の 3 クラスとの一貫性を維持できる +3. 各テーブルが自己完結しており、バグの局所化が容易 +4. ランタイムのパフォーマンスオーバーヘッドがゼロ +5. **Phase 1-R で Misaki 差異が予想よりも多岐にわたることが判明したため、差分方式の利点は薄い** -- マッピングは `private static readonly Dictionary` で宣言する(既存 2 クラスと同一) -- **エントリ順序を `Initial` enum の宣言順に厳密に揃える**(将来 `record PinyinMappingTable` に機械的に変換する際、diff レビューが容易になる) -- 差異エントリには `// Misaki差異: tɕ→ʨ (U+02A8)` の形式でインラインコメントを必須化 +ただし、本チケットのテーブル設計時に差異を明確にドキュメント化し(本チケット自体がその役割を果たす)、Phase 1-R gold standard 137 件との照合を T02 で実施することで、将来の仕様誤り再発を防ぐ。 -**推奨 2: Unicode 定数の名前付き化(既存レビューでも言及済み、さらに具体化)** +### Unicode 定数クラスの導入(強く推奨) ```csharp // src/DotNetG2P.Chinese/Internal/ChineseUnicode.cs @@ -924,12 +716,22 @@ internal static class ChineseUnicode { // IPA 修飾子 public const string Aspirated = "\u02B0"; // ʰ - public const string SyllabicMark = "\u0329"; // 音節主音記号(下付き) - public const string NonSyllabicMark = "\u032F"; // 非音節化記号(下付き反転ブレーブ) // IPA 合字(Misaki 用) public const string TcLigature = "\u02A8"; // ʨ public const string TsLigature = "\u02A6"; // ʦ + public const string RetroflexAffricate = "\uAB67"; // ꭧ + + // IPA 特殊母音(Misaki 用) + public const string SchwaHook = "\u025A"; // ɚ + public const string OpenO = "\u0254"; // ɔ + public const string BarredI = "\u0268"; // ɨ + public const string UpperU = "\u028A"; // ʊ + public const string Epsilon = "\u025B"; // ɛ + public const string Schwa = "\u0259"; // ə + public const string Ramshorn = "\u0264"; // ɤ + public const string TurnedH = "\u0265"; // ɥ + public const string EngNg = "\u014B"; // ŋ // 声調矢印(Misaki 用) public const string ArrowRight = "\u2192"; // → @@ -942,82 +744,80 @@ internal static class ChineseUnicode これにより、T01 のマッピング定義は: ```csharp -private static readonly Dictionary s_initialMisaki = new Dictionary +private static readonly Dictionary s_initialMisaki = new() { - // ... (IPA と同一のエントリは IPA と同じ順序で) [Initial.J] = ChineseUnicode.TcLigature, // Misaki差異: tɕ→ʨ [Initial.Q] = ChineseUnicode.TcLigature + ChineseUnicode.Aspirated, // Misaki差異: tɕʰ→ʨʰ + [Initial.Zh] = ChineseUnicode.RetroflexAffricate, // Misaki差異: ʈʂ→ꭧ [Initial.Z] = ChineseUnicode.TsLigature, // Misaki差異: ts→ʦ - [Initial.C] = ChineseUnicode.TsLigature + ChineseUnicode.Aspirated, // Misaki差異: tsʰ→ʦʰ // ... }; ``` -と書ける。レビュー時の Unicode 誤読リスクが大幅に減り、将来 `record` 化する際もエントリの意味が明示的に保たれる。 +と書ける。レビュー時の Unicode 誤読リスクが大幅に減り、Phase 1-R で判明した 12 項目の差分検証が容易になる。 -**推奨 3: 差分メタテストの導入** +### Kokoro 82M vocab 互換性(Inv6 verified) -T01 のテスト項目に以下を追加: +以下の全てが Kokoro base vocab に含まれることを Phase 1-R で確認済み: -```csharp -[Fact] -public void MisakiDiffersFromIpa_OnlyAtKnownPositions() -{ - // PinyinToIpa と PinyinToMisaki の全エントリを比較し、 - // 差異のあるエントリ数・位置が想定値と一致することを検証 - var ipaInitials = PinyinToIpa.GetInitialMapSnapshot(); // internal テスト API - var misakiInitials = PinyinToMisaki.GetInitialMapSnapshot(); - - var diffKeys = ipaInitials - .Where(kv => misakiInitials[kv.Key] != kv.Value) - .Select(kv => kv.Key) - .ToHashSet(); - - Assert.Equal( - new HashSet { Initial.J, Initial.Q, Initial.Z, Initial.C }, - diffKeys); -} -``` +- `ꭧ` (U+AB67) - Latin Extended-E +- `ʨ` (U+02A8), `ʦ` (U+02A6) - IPA Extensions +- `ɨ` (U+0268), `ɥ` (U+0265) - IPA Extensions +- `ʊ` (U+028A), `ə` (U+0259), `ɤ` (U+0264) - IPA Extensions +- `ɛ` (U+025B), `ɚ` (U+025A), `ɔ` (U+0254) - IPA Extensions +- `→` (U+2192), `↗` (U+2197), `↓` (U+2193), `↘` (U+2198) - Arrows -想定外のエントリ差異を早期検出でき、コピペミスや意図せぬ挙動変更をブロックできる。 +したがって zh/ch に U+AB67、j/q に ʨ、z/c に ʦ を採用しても全て Kokoro に正しくトークン化される。U+032F は vocab 非含有だが、Phase 1-R の仕様ではそもそも使用しないためテンプレ側で自動的に除外される。 -#### アーキテクトレビューとしての最終推奨 +## 8. 後続タスクへの連絡事項 -**T01/T02 のスコープ内では、現行の独立テーブルパターンを踏襲し、上記「推奨 1〜3」を実装する**。理由: - -1. `record + switch` 式の方式 3 は将来の最有力候補だが、3 クラス同時リファクタは Mi1 のスコープ外 -2. 「推奨 1」の**エントリ順序統一**と「推奨 2」の**Unicode 定数化**により、将来の `record` 化が機械的に可能な状態で残せる -3. 「推奨 3」の**差分メタテスト**により、コピペ方式の最大の弱点(テーブル間不整合の見落とし)を補完できる -4. `PinyinToZhuyin` は IPA ファミリとは別系統であり、共通化の対象から除外することで設計目標が明確化される +### T02(Convert メソッド統合)に伝える情報 -**Mi1 完了後の後続タスクとして、別チケット「PinyinConverter 共通抽象化リファクタ」を起票する。** このチケットで: +以下の要点は T02 の `ConvertSyllable` 実装時に必須で参照すること。 -- `PinyinMappingTable` record 型の導入 -- `PinyinConversionEngine.Convert(syllable, table, includeTones)` の抽出 -- `PinyinToIpa` / `PinyinToPiperIpa` / `PinyinToMisaki` をファサードに変更 -- 既存 936 件 + Misaki 追加分のテストが全件通過することで安全性を保証 +1. **テーブルフィールド名**: `s_initialMisaki`, `s_finalMisaki`, `s_toneArrows`, `s_yWCompoundMisaki` を使用。既存 PinyinToIpa の `s_initialIpa` 等と区別する。 -この段階的アプローチにより、**現時点では既存パターンを維持してリスクを最小化**しつつ、**将来の構造改善への布石を残す**ことができる。 +2. **韻母テーブルの構造**: `Dictionary` タプル型。既存 PinyinToIpa / PinyinToPiperIpa の `Dictionary` とは異なるので、ConvertSyllable 実装時は必ず Prefix + ToneArrow + Suffix の順に結合すること。 -## 7. 後続タスクへの連絡事項 +3. **特殊ケース判定の順序** (セクション 3.3 参照): + 1. Initial.None + Final.O → `"ɔ" + toneArrow` + 2. Final.Er → `"ɚ" + toneArrow` + 3. Zh/Ch/Sh/R + Final.I → `initialMisaki[initial] + "ɨ" + toneArrow` + 4. Z/C/S + Final.I → `initialMisaki[initial] + "ɨ" + toneArrow` + 5. Y/W + Final → `s_yWCompoundMisaki` lookup + 6. それ以外 → 標準パス(initial + prefix + toneArrow + suffix) -### T02(Convert メソッド統合)に伝える情報 +4. **Y/W 声母の処理**: Y/W は `s_initialMisaki` に含めない。`s_yWCompoundMisaki[(initial, final)]` を参照し、`OmitInitial` フラグに従って Initial 文字の出力を制御する(yi/yin/ying/yu/yun/wu の 6 ケースで省略)。 -1. **テーブルフィールド名**: `s_initialMisaki`, `s_finalMisaki`, `s_toneMisaki` を使用(PinyinToIpa の `s_initialIpa` 等と区別するため)。 +5. **そり舌・歯茎母音の処理**: PinyinToIpa とは異なり、Misaki は両者とも `ɨ` (U+0268) 単一文字を使用する。別フィールド(`s_retroflexApical` / `s_alveolarApical`)を定義せず、特殊ケース判定内で直接 `"\u0268"` を返す実装でよい。 -2. **そり舌・歯茎母音の処理**: `s_retroflexApical` (`ɻ̩`) と `s_alveolarApical` (`ɹ̩`) は PinyinToIpa と同一値を使用する。PinyinToMisaki 内にも同じフィールドを定義するか、共通化するかは T02 で判断すること。 +6. **声調矢印の位置**: 韻母の Prefix と Suffix の間に挿入する。Suffix が空文字の韻母の場合は末尾付加と等価になる(例: `Final.A` → `prefix="a"`, `suffix=""`, `ma1` → `m + a + → + "" = ma→`)。 -3. **声調の配置位置**: Misaki の矢印声調は音節末に付加する(PinyinToIpa と同じ位置)。 +7. **`ShouldOmitSemivowel` ロジックは廃止**: PinyinToIpa にある `ShouldOmitSemivowel` は Y/W を声母として扱う旧実装向けのロジック。PinyinToMisaki では `s_yWCompoundMisaki.OmitInitial` フラグで代替する。 -4. **Convert メソッドのシグネチャ**: `PinyinToIpa.Convert(string pinyin, bool includeTones)` と同一のシグネチャを推奨。PinyinToPiperIpa のように声調なし固定にはしない(Misaki は声調を使用するため)。 +8. **`U+032F` は使用しない**: Phase 1-R で判明した通り、Misaki は非音節化符号を使わない。`.Replace("\u032F", "")` 等の後処理は不要。 -5. **ShouldOmitSemivowel ロジック**: Y/W 声母の省略判定は PinyinToIpa と同一ロジック。共通化するか PinyinToMisaki にコピーするかは T02 で判断すること。 +9. **gold standard 検証**: `.claude/tmp/misaki-gold.txt` の 137 件を `Fixtures/` にコピーして E2E テストに組み込むこと。少なくとも以下のサンプルは必ず通ること: + - `ma1/2/3/4/5` → `ma→/ma↗/ma↓/ma↘/ma`(声調全パターン) + - `ji1`, `qi2`, `xi3` → `ʨi→`, `ʨʰi↗`, `ɕi↓`(ligature) + - `zhi4`, `chi1`, `shi2`, `ri3` → `ꭧɨ↘`, `ꭧʰɨ→`, `ʂɨ↗`, `ɻɨ↓`(retroflex apical) + - `zi4`, `ci1`, `si2` → `ʦɨ↘`, `ʦʰɨ→`, `sɨ↗`(alveolar apical) + - `bo1`, `po2`, `mo3`, `fo4` → `pwo→`, `pʰwo↗`, `mwo↓`, `fwo↘`(bpmf + o) + - `o1/2/3/4` → `ɔ→/ɔ↗/ɔ↓/ɔ↘`(単独感嘆詞 o) + - `man1`, `mang1` → `ma→n`, `ma→ŋ`(声調中間挿入) + - `lian1`, `jian1` → `ljɛ→n`, `ʨjɛ→n`(ian 処理) + - `ya1`, `yi1`, `yu1`, `yue1`, `yuan1` → `ja→`, `i→`, `y→`, `ɥe→`, `ɥɛ→n`(Y 複合) + - `wa1`, `wu1`, `wang1`, `wen1` → `wa→`, `u→`, `wa→ŋ`, `wə→n`(W 複合) + - `er1/2/3/4` → `ɚ→/ɚ↗/ɚ↓/ɚ↘`(Er 単独) + - `long1`, `dong1`, `xiong2` → `lʊ→ŋ`, `tʊ→ŋ`, `ɕjʊ↗ŋ`(ong / iong) -6. **韻母テーブルの `Iu` / `Ui` の展開形**: PinyinToIpa は `Iu` -> `ioʊ`、`Ui` -> `ueɪ` と展開する。Misaki では `iou̯` / `uei̯` とした。T02 実装時に Misaki 公式出力と照合し、縮約形を使う場合はテーブルを修正すること。 +10. **Convert メソッドのシグネチャ**: `PinyinToIpa.Convert(string pinyin, bool includeTones)` と同一のシグネチャを推奨。PinyinToPiperIpa のように声調なし固定にはしない(Misaki は声調を使用するため)。 -## 8. 紐づけ +## 9. 紐づけ - **マイルストーン**: Mi1(PinyinToMisaki 変換クラス) - **依存**: なし -- **後続**: T02(Convert メソッド統合・ChineseG2PEngine への組み込み) +- **後続**: T02(`ConvertSyllable` メソッド統合・`ChineseG2PEngine` への組み込み) - **関連 Issue**: #56 +- **関連 spec**: `.claude/tmp/misaki-spec.md`(Phase 1-R verified 完全仕様) +- **関連 gold standard**: `.claude/tmp/misaki-gold.txt`(uv misaki 0.9.4 で実測した 137 件) diff --git a/docs/tickets/T02-misaki-convert-method.md b/docs/tickets/T02-misaki-convert-method.md index 9c275ec..879272e 100644 --- a/docs/tickets/T02-misaki-convert-method.md +++ b/docs/tickets/T02-misaki-convert-method.md @@ -1,6 +1,6 @@ --- ticket: T02 -title: PinyinToMisaki Convert メソッド統合 +title: PinyinToMisaki Convert メソッド統合 (Phase 1-R verified) milestone: Mi1 status: 未着手 depends_on: [T01] @@ -9,15 +9,49 @@ blocks: [T03] # T02: PinyinToMisaki Convert メソッド統合 +> **Phase 1-R 仕様全面改訂版**: `misaki==0.9.4` を `uv run` で実測した 137 件の +> gold standard (`.claude/tmp/misaki-gold.txt`) と spec (`.claude/tmp/misaki-spec.md`) +> に基づく「Prefix + Tone + Suffix」テンプレ方式の新設計。 +> 旧版 (U+032F 付与 + 末尾 tone letters 方式) は完全に破棄される。 + ## 1. タスク目的とゴール -T01 で定義済みの声母・韻母・声調の Misaki マッピングテーブル(`s_initialMisaki`, `s_finalMisaki`, 声調矢印テーブル)を使い、`PinyinToMisaki` クラスに `Convert()` および `ConvertSyllable()` メソッドを実装する。 +T01 で Phase 1-R 仕様に基づき再定義された Misaki マッピングテーブル (`s_initialMisaki`, +`s_finalMisaki` [Prefix/Suffix 構造]、`s_yWCompoundMisaki` [Y/W 複合 final]、 +`s_toneArrows`) を使い、`PinyinToMisaki` クラスに **新しい ConvertSyllable パイプライン** +を実装する。 **ゴール:** -- `PinyinToMisaki.Convert(string pinyin)` — ピンイン文字列を受け取り、Misaki 互換の音素文字列を返す -- `PinyinToMisaki.ConvertSyllable(PinyinSyllable syllable, bool includeTones)` — パース済み音節構造体から Misaki 文字列を生成する -- `dotnet build` が通過すること -- Convert 単体のユニットテストが全件パスすること + +- `PinyinToMisaki.Convert(string pinyin)` — 数字声調/声調記号付きピンイン文字列を受け取り、 + Misaki 互換の音素文字列を返す (声調矢印付き、デフォルト `includeTones = true`) +- `PinyinToMisaki.Convert(string pinyin, bool includeTones)` — 声調矢印の有無を制御可能 +- `PinyinToMisaki.ConvertSyllable(PinyinSyllable syllable, bool includeTones)` — + パース済み `PinyinSyllable` を受け取り、**Prefix + Tone + Suffix** の新パイプラインで + Misaki 文字列を生成する +- `dotnet build DotNetG2P.slnx` が通過すること +- Convert 単体のユニットテスト (`PinyinToMisakiConvertTests.cs`) が 120+ ケース全件パスすること +- **全期待値は `.claude/tmp/misaki-gold.txt` の `misaki==0.9.4` 実測値と照合する** + (verified against misaki 0.9.4 via `uv run python -c ...`) + +### 新設計の核心 — なぜ書き直すのか + +旧 T02 (2026-04-12 以前) は `PinyinToIpa` の構造を流用し「声母 + 韻母 + 末尾 tone letter」という +Linear-Append パイプラインを前提としていた。しかし Phase 1-R の実測で以下が判明した: + +| 要素 | 旧 T02 の前提 | 実測 (misaki 0.9.4) | +|-----|-------------|------------------| +| 声調位置 | 末尾付与 (`ma` + `˥˥` → `ma˥˥`) | **韻母の prefix と suffix の間** (`ma` + `→` / `ma` + `→n`) | +| Y/W 処理 | `ShouldOmitSemivowel` 分岐で声母のみ省略 | **複合 final テーブル** 参照 (`Y+Ong` → `jʊ→ŋ`、`W+En` → `wə→n` 等) | +| そり舌 (zh/ch/sh/r + i) | `ɻ̩` (U+027B U+0329) | `ɨ` (U+0268) 直接 | +| 歯茎 (z/c/s + i) | `ɹ̩` (U+0279 U+0329) | `ɨ` (U+0268) 直接 | +| 感嘆詞 (Initial.None + Final.O) | `wo` テンプレそのまま | **特別ケース** → `ɔ` (U+0254) | +| Er 韻母 | `ɚ` 単純置換 | Initial あっても声母 + `ɚ` + tone | +| U+032F (非音節化符号) | 出力に含める | **出力に含めない** (テンプレ側で事前除去済) | + +この結果、旧パイプラインでは「末尾 tone」「声母出力制御」「特殊母音の後処理」がいずれも +実測と噛み合わず、最低でも 40% のテストケースで不一致を発生させることが確定した。 +Phase 1-R で Prefix + Tone + Suffix 方式に全面移行する。 ## 2. 実装する内容の詳細 @@ -27,7 +61,16 @@ T01 で定義済みの声母・韻母・声調の Misaki マッピングテー src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs ``` -T01 で作成済みのファイルにメソッドを追加する形を想定。T01 で既にクラスの骨格とマッピングテーブルが定義されている前提。 +T01 (Phase 1-R 版) で既に以下が定義済みの前提: + +- `s_initialMisaki` (21 エントリ、Y/W 含む。Y/W の値は後述の「Y/W 処理」で詳述) +- `s_finalMisaki` — **新設計**: `Dictionary` として + prefix と suffix を分離保持 (旧版の単一 string からの破壊的変更) +- `s_yWCompoundMisaki` — **新設計**: `Dictionary<(Initial, Final), (string Prefix, string Suffix, bool OmitInitial)>` + として Y/W + Final の複合パターンを保持 (T02 で参照する主要テーブル) +- `s_toneArrows` — 5 要素配列 `["", "→", "↗", "↓", "↘"]` (Neutral/1/2/3/4) + +T02 では上記テーブルを参照する `Convert` / `ConvertSyllable` メソッドのみを追加する。 ### 2.2 メソッドシグネチャ @@ -36,933 +79,1021 @@ namespace DotNetG2P.Chinese { internal static class PinyinToMisaki { - // --- T01 で定義済み --- + // --- T01 (Phase 1-R) で定義済み --- // private static readonly Dictionary s_initialMisaki; - // private static readonly Dictionary s_finalMisaki; - // private static readonly string[] s_toneArrows; // 声調矢印テーブル + // private static readonly Dictionary s_finalMisaki; + // private static readonly Dictionary<(Initial, Final), (string Prefix, string Suffix, bool OmitInitial)> s_yWCompoundMisaki; + // private static readonly string[] s_toneArrows; // ["", "→", "↗", "↓", "↘"] // --- T02 で実装 --- /// - /// 声調記号付きピンインを Misaki 互換表記に変換する(声調矢印付き)。 + /// 声調矢印付きピンインを Misaki 互換表記に変換する (includeTones = true)。 /// + /// 数字声調形式 ("ma1") または声調記号付き ("mā") のピンイン文字列。 + /// Misaki 互換音素文字列。失敗時は空文字列。 public static string Convert(string pinyin); /// - /// 声調記号付きピンインを Misaki 互換表記に変換する。 + /// 声調矢印の付与を制御しながらピンインを Misaki 互換表記に変換する。 /// public static string Convert(string pinyin, bool includeTones); /// - /// PinyinSyllable を Misaki 互換表記に変換する。 + /// パース済み音節構造体を Misaki 互換表記に変換する (新 Prefix + Tone + Suffix パイプライン)。 /// internal static string ConvertSyllable(PinyinSyllable syllable, bool includeTones); } } ``` -### 2.3 処理フロー — Convert() +引数なし `Convert(string pinyin)` は `Convert(pinyin, true)` に委譲する +(既存 `PinyinToIpa` と同一パターン)。 + +### 2.3 ConvertSyllable 新パイプライン (5 ステップ) -既存の `PinyinToIpa.Convert()` と同一の構造を踏襲する。 +**これが Phase 1-R の核心変更である。** 必ず以下の 5 ステップを **この順序で** 実行すること。 ``` -入力: ピンイン文字列 (例: "zhōng", "ma1", "lǜ") - │ - ├─ 1. null/空チェック → string.Empty を返す - │ - ├─ 2. ToneConverter.ToToneMarked(pinyin) で数字声調形式を声調記号付きに正規化 - │ 例: "ma1" → "mā" - │ - ├─ 3. PinyinParser.TryParse(normalized, out syllable) - │ パース失敗 → string.Empty を返す - │ 例: "zhōng" → PinyinSyllable(Initial.Zh, Final.Ong, Tone.First) - │ - └─ 4. ConvertSyllable(syllable, includeTones) を呼び出して結果を返す +ConvertSyllable(syllable, includeTones): + +1. 声調矢印決定 + ───────────── + string toneArrow = (includeTones && syllable.Tone != Tone.Neutral) + ? s_toneArrows[(int)syllable.Tone] + : string.Empty; + +2. 特別ケース判定 (早期 return) + ───────────────────────────── + a. Initial.None + Final.O + → return "\u0254" + toneArrow; // ɔ (単独感嘆詞 ō/ó/ǒ/ò) + + b. Final.Er + → 声母があれば s_initialMisaki[initial] + "\u025A" + toneArrow; + なければ "\u025A" + toneArrow; // ɚ (U+025A) + + c. (Zh|Ch|Sh|R) + Final.I (retroflex apical) + → return s_initialMisaki[initial] + "\u0268" + toneArrow; // ɨ + + d. (Z|C|S) + Final.I (alveolar apical) + → return s_initialMisaki[initial] + "\u0268" + toneArrow; // ɨ + +3. Y/W + Final 複合ルックアップ + ──────────────────────────── + if (s_yWCompoundMisaki.TryGetValue((syllable.Initial, syllable.Final), out var comp)) + { + // hit: テーブルから (prefix, suffix, omitInitial) を取得 + prefix = comp.Prefix; + suffix = comp.Suffix; + omitInitial = comp.OmitInitial; + } + else + { + // miss: standard path + var entry = s_finalMisaki[syllable.Final]; // (Prefix, Suffix) + prefix = entry.Prefix; + suffix = entry.Suffix; + omitInitial = false; + } + +4. 声母出力 + ──────── + var sb = new StringBuilder(16); + if (!omitInitial && syllable.Initial != Initial.None + && s_initialMisaki.TryGetValue(syllable.Initial, out var initialStr)) + { + sb.Append(initialStr); + } + +5. 構築 (Prefix → Tone → Suffix の順) + ──────────────────────────────────── + sb.Append(prefix); + sb.Append(toneArrow); + sb.Append(suffix); + return sb.ToString(); ``` -引数なし `Convert(string pinyin)` は `Convert(pinyin, true)` に委譲する(PinyinToIpa と同一パターン)。 - -### 2.4 処理フロー — ConvertSyllable() +**ポイント:** + +- **ステップ 2 の順序は固定**: `(a) ó 感嘆詞` → `(b) Er` → `(c) retroflex` → `(d) alveolar`。 + 特に `(a)` は `(b)` より前にチェックする必要がある (Final.Er と Final.O は排他なので + 実際の順序影響はないが、可読性のため固定)。 +- **ステップ 3 は lookup 中心**: 旧版の `ShouldOmitSemivowel` ヘルパー関数は不要。 + `s_yWCompoundMisaki` テーブルがその役割を完全に引き継ぐ。 +- **ステップ 5 の順序**: 必ず `prefix → toneArrow → suffix` の順。 + Misaki の声調は「韻母の核母音の後、後続子音の前」に入るため、 + `ma→n` (man1) / `jʊ→ŋ` (yong1) のようにテンプレの中間に挟まる構造になる。 +- **ステップ 1 の `toneArrow` は空文字列を許容**: `includeTones=false` や `Tone.Neutral` + (軽声) の場合は空文字列が入り、ステップ 5 で `Append("")` されるだけ (問題なし)。 + +### 2.4 既存 PinyinToIpa との主要差分 + +| 項目 | PinyinToIpa (既存) | PinyinToMisaki (Phase 1-R) | +|------|-----------------|-------------------------| +| マッピング構造 | `Dictionary` (単一 string) | **`Dictionary` (タプル)** | +| 声調位置 | 末尾付与 (`s_toneLetters` を末尾 Append) | **韻母 prefix と suffix の間** (中置) | +| 声調記号 | IPA tone letters (`˥˥`/`˧˥`/`˨˩˦`/`˥˩`) | 矢印 (`→`/`↗`/`↓`/`↘`) | +| Y/W 処理 | `ShouldOmitSemivowel` ヘルパー関数 + `s_initialIpa[Y/W]` 出力 | **`s_yWCompoundMisaki` 複合 final lookup** | +| そり舌 (zh/ch/sh/r + i) | `s_retroflexApical` = `ɻ̩` (U+027B U+0329) | **`ɨ` (U+0268) 直接** | +| 歯茎 (z/c/s + i) | `s_alveolarApical` = `ɹ̩` (U+0279 U+0329) | **`ɨ` (U+0268) 直接** | +| 感嘆詞 (Initial.None + Final.O) | 標準ルートで `wo` 出力 | **特別ケース** → `ɔ` (U+0254) | +| Final.Er | `ɚ` (U+025A) 単純 | 同上 (声母ありの稀ケースもサポート) | +| U+032F (非音節化符号) | 付与する (`ai̯` 等) | **付与しない** (テンプレ側で事前除去済み) | +| includeTones=false 時 | 声調 letters のみ省略 | 矢印のみ省略、テンプレは維持 | +| StringBuilder 使用 | あり (16 容量) | あり (16 容量、同一) | +| IsRetroflex / IsAlveolar | private static メソッド | **switch 式 or 直接判定** (ヘルパー関数化しない) | + +### 2.5 参照すべき既存コード -これが変換の中核ロジックである。`PinyinToIpa.ConvertSyllable()` の全ロジックフローを踏襲しつつ、マッピングテーブルのみを Misaki 用に差し替える。 +| ファイル | 参照理由 | +|---------|---------| +| `src/DotNetG2P.Chinese/Conversion/PinyinToIpa.cs` | `Convert` の入口処理 (null → ToneConverter → PinyinParser → ConvertSyllable) のテンプレート | +| `src/DotNetG2P.Chinese/Conversion/PinyinToPiperIpa.cs` | 同構造の別バリアント。構造参考用 | +| `src/DotNetG2P.Chinese/Conversion/PinyinParser.cs` | TryParse の仕様。j/q/x/y 後の u→v 正規化ロジック。lv/nv → Final.V 系への変換 | +| `src/DotNetG2P.Chinese/Conversion/ToneConverter.cs` | ToToneMarked / ExtractTone の仕様 | +| `src/DotNetG2P.Chinese/Models/PinyinSyllable.cs` | Initial/Final/Tone enum の構造体定義 | +| `.claude/tmp/misaki-spec.md` | **Phase 1-R 仕様書** (verified against misaki 0.9.4) | +| `.claude/tmp/misaki-gold.txt` | **137 件の gold standard** (uv run 実測) | + +## 3. Y/W 処理ロジック詳細 + +Phase 1-R の最大の設計変更点は、Y/W を「声母マップ単独 + ShouldOmitSemivowel 関数」ではなく +**「複合 final テーブルによる一括 lookup」で処理する** 点である。 + +### 3.1 DotNetG2P PinyinParser の構造 + +`PinyinParser` は "wang" を `Initial.W + Final.Ang` に、"yue" を `Initial.Y + Final.Ve` に +パースする (Misaki Python 側の "uang"/"üe" とは異なる内部表現)。したがって +`ConvertSyllable` で以下の変換を行う必要がある: + +### 3.2 Y/W 複合 final 変換テーブル + +T01 で定義する `s_yWCompoundMisaki` は以下の 24 エントリを持つ +(仕様は `.claude/tmp/misaki-spec.md` の「Y/W 複合韻母マッピング」表と完全一致)。 + +| Initial | Final | Misaki 等価 | Prefix | Suffix | OmitInitial | 備考 | +|---------|-------|------------|--------|--------|------------|-----| +| Y | A | Ia | `ja` | `` | No | ya → ia | +| Y | An | Ian | `jɛ` | `n` | No | yan → ian | +| Y | Ang | Iang | `ja` | `ŋ` | No | yang | +| Y | Ao | Iao | `jau` | `` | No | yao | +| Y | E | Ie | `je` | `` | No | ye | +| Y | I | I | `i` | `` | **Yes** (j 省略) | yi | +| Y | In | In | `i` | `n` | **Yes** | yin | +| Y | Ing | Ing | `i` | `ŋ` | **Yes** | ying | +| Y | Ong | Iong | `jʊ` | `ŋ` | No | yong | +| Y | Ou | Iu (iou) | `jou` | `` | No | you | +| Y | V | V (ü) | `y` | `` | **Yes** (ɥ 省略) | yu | +| Y | Ve | Ve (üe) | `ɥe` | `` | No | yue | +| Y | Van | Van (üan) | `ɥɛ` | `n` | No | yuan | +| Y | Vn | Vn (ün) | `y` | `n` | **Yes** (ɥ 省略) | yun | +| W | A | Ua | `wa` | `` | No | wa → ua | +| W | Ai | Uai | `wai` | `` | No | wai | +| W | An | Uan | `wa` | `n` | No | wan | +| W | Ang | Uang | `wa` | `ŋ` | No | wang | +| W | Ei | Ui (uei) | `wei` | `` | No | wei | +| W | En | Un (uen) | `wə` | `n` | No | wen | +| W | Eng | Ueng | `wə` | `ŋ` | No | weng | +| W | O | Uo | `wo` | `` | No | wo | +| W | U | U | `u` | `` | **Yes** (w 省略) | wu | + +### 3.3 テーブル参照コードの具体例 -以下に `PinyinToIpa.ConvertSyllable()` の全ロジックフローを詳述する。 +```csharp +// T01 で定義済み +private static readonly Dictionary<(Initial, Final), (string Prefix, string Suffix, bool OmitInitial)> + s_yWCompoundMisaki = new Dictionary<(Initial, Final), (string, string, bool)> +{ + // Y 系 (14 エントリ) + [(Initial.Y, Final.A)] = ("ja", "", false), + [(Initial.Y, Final.An)] = ("j\u025B", "n", false), // jɛn + [(Initial.Y, Final.Ang)] = ("ja", "\u014B", false), // jaŋ + [(Initial.Y, Final.Ao)] = ("jau", "", false), + [(Initial.Y, Final.E)] = ("je", "", false), + [(Initial.Y, Final.I)] = ("i", "", true), // yi → i (j省略) + [(Initial.Y, Final.In)] = ("i", "n", true), // yin → in (j省略) + [(Initial.Y, Final.Ing)] = ("i", "\u014B", true), // ying → iŋ (j省略) + [(Initial.Y, Final.Ong)] = ("j\u028A", "\u014B", false), // jʊŋ + [(Initial.Y, Final.Ou)] = ("jou", "", false), // you → iou + [(Initial.Y, Final.V)] = ("y", "", true), // yu → y (ɥ省略) + [(Initial.Y, Final.Ve)] = ("\u0265e", "", false), // yue → ɥe + [(Initial.Y, Final.Van)] = ("\u0265\u025B", "n", false), // yuan → ɥɛn + [(Initial.Y, Final.Vn)] = ("y", "n", true), // yun → yn (ɥ省略) + + // W 系 (9 エントリ) + [(Initial.W, Final.A)] = ("wa", "", false), + [(Initial.W, Final.Ai)] = ("wai", "", false), + [(Initial.W, Final.An)] = ("wa", "n", false), + [(Initial.W, Final.Ang)] = ("wa", "\u014B", false), + [(Initial.W, Final.Ei)] = ("wei", "", false), + [(Initial.W, Final.En)] = ("w\u0259", "n", false), // wə n + [(Initial.W, Final.Eng)] = ("w\u0259", "\u014B", false), // wə ŋ + [(Initial.W, Final.O)] = ("wo", "", false), + [(Initial.W, Final.U)] = ("u", "", true), // wu → u (w省略) +}; +``` -#### ステップ A: StringBuilder 初期化 +### 3.4 ConvertSyllable でのルックアップコード例 ```csharp -var sb = new StringBuilder(16); +// ステップ 3: Y/W + Final 複合ルックアップ +string prefix; +string suffix; +bool omitInitial; + +if (s_yWCompoundMisaki.TryGetValue((syllable.Initial, syllable.Final), out var compound)) +{ + prefix = compound.Prefix; + suffix = compound.Suffix; + omitInitial = compound.OmitInitial; +} +else +{ + // 標準パス: 通常の Final テーブルから取得 (Y/W 以外、または Y/W だが + // テーブルに載っていない組み合わせ — 実際には発生しないはずだが防御的に) + if (!s_finalMisaki.TryGetValue(syllable.Final, out var standardEntry)) + { + // 理論上到達しない (全 Final がテーブルに定義されている前提) + return string.Empty; + } + prefix = standardEntry.Prefix; + suffix = standardEntry.Suffix; + omitInitial = false; +} ``` -16 文字の初期容量で StringBuilder を作成。Misaki 表記でも十分なサイズ。 +### 3.5 判定ロジック例: yi1 (Y + I + Tone 1) -#### ステップ B: 声母の変換 +入力: `PinyinSyllable { Initial = Y, Final = I, Tone = First }`、`includeTones = true` -``` -syllable.Initial != Initial.None の場合: - │ - ├─ (a) Initial が Y または W の場合 → 半母音省略判定 - │ │ - │ ├─ ShouldOmitSemivowel(Initial, Final) == true - │ │ → 声母を出力しない(韻母が既に対応する半母音で始まるため) - │ │ - │ └─ ShouldOmitSemivowel == false - │ → s_initialMisaki[syllable.Initial] を出力 - │ - └─ (b) それ以外の声母 - → s_initialMisaki[syllable.Initial] を出力 -``` +1. ステップ 1: `toneArrow = "→"` +2. ステップ 2: Initial.None でない、Final.Er でない、retroflex でない、alveolar でない → 通過 +3. ステップ 3: `s_yWCompoundMisaki[(Y, I)]` → `("i", "", true)` hit + - `prefix = "i"`, `suffix = ""`, `omitInitial = true` +4. ステップ 4: `omitInitial == true` → 声母出力 **スキップ** +5. ステップ 5: `sb.Append("") + Append("i") + Append("→") + Append("")` → `"i→"` -**ShouldOmitSemivowel の判定ロジック(PinyinToIpa から完全再利用):** +**期待値 (gold.txt):** `yi1 → i→` ✓ 一致 -- `Initial.Y` の場合、以下の韻母なら半母音 j を省略: - - `Final.I`, `Final.In`, `Final.Ing` — 韻母の IPA/Misaki が `i` で始まるため - - `Final.V`, `Final.Ve`, `Final.Van`, `Final.Vn` — 韻母の IPA/Misaki が `y`(円唇前舌高母音)で始まるため -- `Initial.W` の場合、以下の韻母なら半母音 w を省略: - - `Final.U`, `Final.Un` — 韻母の IPA/Misaki が `u` で始まるため -- それ以外 → 省略しない (false) +### 3.6 判定ロジック例: wen1 (W + En + Tone 1) -#### ステップ C: 韻母の変換(特殊母音の分岐を含む) +入力: `PinyinSyllable { Initial = W, Final = En, Tone = First }`、`includeTones = true` -``` -syllable.Final != Final.None の場合: - │ - ├─ (c1) Final == Final.I かつ IsRetroflex(Initial) == true - │ → そり舌母音を出力(後述の特殊母音処理を参照) - │ 対象声母: Initial.Zh, Initial.Ch, Initial.Sh, Initial.R - │ 例: "zhi" → 声母(zh の Misaki) + そり舌母音(Misaki) - │ - ├─ (c2) Final == Final.I かつ IsAlveolar(Initial) == true - │ → 歯茎母音を出力(後述の特殊母音処理を参照) - │ 対象声母: Initial.Z, Initial.C, Initial.S - │ 例: "zi" → 声母(z の Misaki) + 歯茎母音(Misaki) - │ - └─ (c3) 上記以外 - → s_finalMisaki[syllable.Final] を出力 -``` +1. ステップ 1: `toneArrow = "→"` +2. ステップ 2: いずれの特別ケースにも該当しない → 通過 +3. ステップ 3: `s_yWCompoundMisaki[(W, En)]` → `("wə", "n", false)` hit + - `prefix = "wə"`, `suffix = "n"`, `omitInitial = false` +4. ステップ 4: `omitInitial == false`、Initial != None だが **W は複合 final で吸収済み** のため + `s_initialMisaki[Initial.W]` **を出力してはならない** 。 -**IsRetroflex の判定:** -```csharp -Initial == Initial.Zh || Initial == Initial.Ch || Initial == Initial.Sh || Initial == Initial.R -``` + ⚠ **重要**: この設計では `s_yWCompoundMisaki` にエントリがある場合、prefix 側で + 既に `w` や `j` が含まれている (`wə` の `w`) ため、`omitInitial = false` であっても + `s_initialMisaki[Initial.W]` は出力しない。 + `omitInitial` は「声母出力をスキップするか」を示すフラグであり、テーブルに hit した + 時点で `W`/`Y` の声母は prefix 側に織り込まれている扱いとなる。 -**IsAlveolar の判定:** -```csharp -Initial == Initial.Z || Initial == Initial.C || Initial == Initial.S -``` + 実装としては **Y/W の場合は常に声母出力スキップ** になる。正確なコード: -#### ステップ D: 声調矢印の付与 + ```csharp + // ステップ 4 (訂正版) + if (syllable.Initial != Initial.None && syllable.Initial != Initial.Y && syllable.Initial != Initial.W) + { + if (s_initialMisaki.TryGetValue(syllable.Initial, out var initialStr)) + sb.Append(initialStr); + } + ``` -``` -includeTones == true かつ syllable.Tone != Tone.Neutral の場合: - → s_toneArrows[(int)syllable.Tone] を出力 -``` + `omitInitial` フラグは「Y/W 以外で声母を省略したい特殊ケース (今のところなし)」 + のための**将来拡張用**と位置づけるか、または **Y/W 複合 final lookup hit 時は + omitInitial の値に関わらず常に省略** とする。後者が単純で実装推奨。 +5. ステップ 5: `sb.Append("wə") + Append("→") + Append("n")` → `"wə→n"` -Misaki では IPA の声調文字 (˥˥ 等) の代わりに矢印文字を使用する。具体的なマッピングは T01 で定義済み。 +**期待値 (gold.txt):** `wen1 → wə→n` ✓ 一致 -#### ステップ E: 結果の返却 +### 3.7 omitInitial フラグの真の用途 -```csharp -return sb.ToString(); -``` +再整理すると、`omitInitial = true` のエントリは `yi/yin/ying/yu/yun/wu` の 6 種である。 +これらは **prefix 側にも `j` や `w` が含まれていない** (`"i"`, `"u"`, `"y"` で始まる)。 +一方 `omitInitial = false` のエントリ (例: `ya → (ja, )`, `wa → (wa, )`) は +**prefix 側に `j` や `w` が既に含まれている**。 -### 2.5 PinyinToIpa との構造比較 — 同一部分と差異 +したがって実装としては以下のどちらでも正しく動作する: -| 項目 | PinyinToIpa | PinyinToMisaki | -|------|-------------|----------------| -| Convert() の入口処理 | 同一 | **同一**: null チェック → ToneConverter → PinyinParser → ConvertSyllable | -| 半母音省略判定 | ShouldOmitSemivowel | **同一ロジック**: Y/W + 特定韻母で省略 | -| そり舌母音 (zh/ch/sh/r + i) | `ɻ̩` (U+027B U+0329) | **差異**: T01 で定義する Misaki 表記 | -| 歯茎母音 (z/c/s + i) | `ɹ̩` (U+0279 U+0329) | **差異**: T01 で定義する Misaki 表記 | -| 声母マッピング | s_initialIpa | **差異**: s_initialMisaki(T01 定義) | -| 韻母マッピング | s_finalIpa | **差異**: s_finalMisaki(T01 定義) | -| 声調マッピング | s_toneLetters (IPA tone letters) | **差異**: s_toneArrows(Misaki 矢印記号、T01 定義) | -| includeTones パラメータ | あり | **同一**: true/false で声調出力を制御 | -| IsRetroflex / IsAlveolar | private static メソッド | **同一ロジック**: 再実装 or 共有ヘルパーから呼び出し | +**案 A (推奨): Y/W は常に声母出力スキップ** -### 2.6 特殊母音の処理 — そり舌母音・歯茎母音 +```csharp +if (syllable.Initial != Initial.None + && syllable.Initial != Initial.Y + && syllable.Initial != Initial.W) +{ + sb.Append(s_initialMisaki[syllable.Initial]); +} +``` -中国語音韻学上、以下の2つの特殊母音はピンイン表記上「i」と書かれるが、実際の発音は通常の /i/ とは全く異なる。 +`omitInitial` フラグは使わず、テーブル側で `prefix` に `j`/`w` を埋め込むことで +対応する。シンプル・予測可能。**本チケットはこの案 A を採用する。** -**そり舌母音(zhi, chi, shi, ri の韻母):** -- IPA: `ɻ̩` (U+027B U+0329 — そり舌接近音 + 音節主音) -- piper-plus: `ɻ̩` (U+027B U+0329 — IPA と同一) -- Misaki: T01 のマッピングテーブルで定義(要確認) +**案 B: omitInitial フラグを使う** -**歯茎母音(zi, ci, si の韻母):** -- IPA: `ɹ̩` (U+0279 U+0329 — 歯茎接近音 + 音節主音) -- piper-plus: `ɨ` (U+0268 — 非円唇中舌高母音、簡略表記) -- Misaki: T01 のマッピングテーブルで定義(要確認) +```csharp +if (!omitInitial + && syllable.Initial != Initial.None + && s_initialMisaki.TryGetValue(syllable.Initial, out var initialStr)) +{ + sb.Append(initialStr); +} +``` -実装では `s_retroflexApical` および `s_alveolarApical` として static readonly string フィールドに定義する(PinyinToIpa/PinyinToPiperIpa と同一パターン)。 +`s_initialMisaki[Initial.Y]` と `[Initial.W]` を空文字列にする必要があり、 +それは T01 の声母マッピングの意味論と矛盾する (エンジンは Y/W を「半母音声母」として扱うため)。 +→ 案 A のほうが整合性が高い。 -### 2.7 参照すべき既存コード +### 3.8 最終確定: ステップ 4 のコード -| ファイル | 参照理由 | -|---------|---------| -| `src/DotNetG2P.Chinese/Conversion/PinyinToIpa.cs` | Convert/ConvertSyllable の構造テンプレート、ShouldOmitSemivowel/IsRetroflex/IsAlveolar のロジック | -| `src/DotNetG2P.Chinese/Conversion/PinyinToPiperIpa.cs` | 同構造の別バリアント。歯茎母音の表記差異 (`ɹ̩` vs `ɨ`) の参考 | -| `src/DotNetG2P.Chinese/Conversion/PinyinParser.cs` | TryParse の仕様。j/q/x/y 後の u→v 正規化ロジック | -| `src/DotNetG2P.Chinese/Conversion/ToneConverter.cs` | ToToneMarked / ExtractTone の仕様 | -| `src/DotNetG2P.Chinese/Models/PinyinSyllable.cs` | Initial/Final/Tone の構造体定義 | +```csharp +// ステップ 4: 声母出力 (Y/W 以外) +if (syllable.Initial != Initial.None + && syllable.Initial != Initial.Y + && syllable.Initial != Initial.W + && s_initialMisaki.TryGetValue(syllable.Initial, out var initialStr)) +{ + sb.Append(initialStr); +} +``` + +`omitInitial` フラグは T01 のテーブル定義としては記載を許容するが (将来の拡張用 + 仕様書の +表構造との対応のため)、T02 の実装では読み取らない方針でよい。 -## 3. 実装するために必要なエージェントチームの役割と人数 +## 4. 実装エージェントチームの役割と人数 | 役割 | 人数 | 担当内容 | |------|------|---------| -| 実装エージェント | 1名 | PinyinToMisaki.cs に Convert/ConvertSyllable/ShouldOmitSemivowel/IsRetroflex/IsAlveolar を実装 | -| テストエージェント | 1名 | ユニットテスト作成。Convert 単体テスト、特殊母音テスト、半母音省略テスト、声調テスト | -| レビューエージェント | 1名 | PinyinToIpa との構造一貫性確認、マッピングテーブル (T01) との整合性検証、エッジケースのレビュー | +| **実装エージェント** | 1名 | `PinyinToMisaki.cs` に Convert (2 オーバーロード) + ConvertSyllable (新 5 ステップパイプライン) を追加。T01 のテーブル構造を参照して StringBuilder ベースで実装 | +| **テストエージェント** | 1名 | `PinyinToMisakiConvertTests.cs` を新規作成。セクション 5 の 17 カテゴリ × 代表ケース 5-10 件、計 120+ ケース。全期待値は `.claude/tmp/misaki-gold.txt` から引用 | +| **統合レビューエージェント** | 1名 | T01 テーブルとの整合性検証 (特に `s_yWCompoundMisaki` の 24 エントリ)、ステップ順序の正しさ (特別ケースの優先度)、U+032F 残留チェック、エッジケース (null/空/不正 pinyin) のレビュー | -合計 **3名**。実装自体は PinyinToIpa のコピー&マッピング差し替えが主であり、1名の実装エージェントで十分対応可能。テストとレビューを並行して進めることで効率化できる。 +合計 **3 名**。実装自体は PinyinToIpa のパターン流用 + マッピング差し替え + Y/W テーブル lookup 追加 +であり、1 名で対応可能。テスト作成とレビューを並行することで Mi1 マイルストーン内に収める。 -## 4. 提供範囲とテスト項目 +## 5. スコープとテスト項目 -### 4.1 スコープ +### 5.1 スコープ **含む:** + - `PinyinToMisaki.Convert(string)` メソッド - `PinyinToMisaki.Convert(string, bool)` メソッド - `PinyinToMisaki.ConvertSyllable(PinyinSyllable, bool)` メソッド -- 上記に必要な private ヘルパー (`ShouldOmitSemivowel`, `IsRetroflex`, `IsAlveolar`) -- そり舌母音・歯茎母音の static readonly フィールド (`s_retroflexApical`, `s_alveolarApical`) -- Convert 単体のユニットテスト - -**含まない:** -- ChineseG2PEngine への `ToMisaki()` API 追加(T03 のスコープ) -- マッピングテーブルの定義(T01 のスコープ) -- バッチ API (`ConvertBatch` 等) -- `ConvertToPhonemes()` メソッド(PinyinToPiperIpa にあるが、Misaki で必要かは T03 以降で判断) - -### 4.2 ユニットテスト案 +- 新規テストファイル `tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiConvertTests.cs` (rewrite) -テストファイル: `tests/DotNetG2P.Tests/Chinese/PinyinToMisakiTests.cs` +**含まない (別チケット):** -| テストカテゴリ | テストケース例 | 検証内容 | -|---------------|-------------|---------| -| **基本変換** | "mā" → 期待 Misaki 出力 | 声母 m + 韻母 a + 第1声矢印 | -| **全声調** | "mā", "má", "mǎ", "mà", "ma" | 各声調の矢印が正しいこと。軽声は矢印なし | -| **数字声調入力** | "ma1", "ma2", "ma3", "ma4" | ToneConverter 経由で正しく変換されること | -| **includeTones=false** | "mā" (tones=false) | 声調矢印が付かないこと | -| **そり舌母音** | "zhī", "chī", "shī", "rì" | zh/ch/sh/r + i で特殊母音が出力されること | -| **歯茎母音** | "zī", "cī", "sī" | z/c/s + i で特殊母音が出力されること | -| **半母音省略 (Y)** | "yī" (yi), "yīn" (yin) | j が省略されること | -| **半母音付与 (Y)** | "yā" (ya), "yáo" (yao) | j が付与されること | -| **半母音省略 (W)** | "wū" (wu) | w が省略されること | -| **半母音付与 (W)** | "wā" (wa), "wǒ" (wo) | w が付与されること | -| **ü 系韻母** | "lǜ", "nǚ", "jú" (→ jü) | V/Ve/Van/Vn 韻母が正しく変換されること | -| **null/空文字** | null, "", " " | string.Empty が返ること | -| **パース失敗** | "xyz", "123" | string.Empty が返ること | -| **全声母網羅** | b,p,m,f,d,t,n,l,g,k,h,j,q,x,zh,ch,sh,r,z,c,s 各1例 | 全21声母のマッピングが正しいこと | -| **全韻母網羅** | 各韻母の代表音節1例ずつ (36種) | 全36韻母のマッピングが正しいこと | -| **er 韻母** | "ér" | 特殊韻母 er の変換が正しいこと | +- `ChineseG2PEngine.ToMisaki()` API 追加 (T03) +- マッピングテーブル定義 (T01 — **Phase 1-R 版に rewrite 済みの前提**) +- バッチ API (`ConvertBatch`) (T03) +- Multilingual 統合 (T03-T05) -## 5. 実装に関する懸念事項とレビュー項目 +### 5.2 テストファイル: `PinyinToMisakiConvertTests.cs` (rewrite) -### 5.1 PinyinParser 依存 +旧テスト (`PinyinToMisakiTests.cs` 等) は全て破棄し、新ファイルを起こす。 -`Convert()` は `PinyinParser.TryParse()` に完全依存している。PinyinParser は j/q/x/y 後の `u` を `v`(ü系韻母)として正規化する処理を内包しており、この挙動を前提としている。PinyinParser 自体の変更は不要だが、Misaki のマッピングが ü 系韻母 (Final.V, Ve, Van, Vn) を正しくカバーしていることを T01 レビュー時に確認すること。 +> **期待値の出所**: 以下すべてのテストケースの expected 値は +> `.claude/tmp/misaki-gold.txt` から引用しており、これは +> `uv run python -c "from misaki.zh import ZHG2P; g=ZHG2P(); print(g('...'))"` で +> **misaki==0.9.4 を直接実行** して取得した実測値 (137 件) である。 +> ハードコード禁止。将来仕様が変わった場合は `.claude/tmp/misaki-gold.txt` を +> `misaki` の新バージョンで再生成してからテスト期待値を更新すること。 -### 5.2 ShouldOmitSemivowel の再利用 +### 5.3 テスト項目カテゴリ -現在 `PinyinToIpa` と `PinyinToPiperIpa` に全く同一の `ShouldOmitSemivowel` が private static で個別に存在する。`PinyinToMisaki` にも同一ロジックを3つ目としてコピーすることになる。 +#### カテゴリ 1: 4 声 + 軽声 × ma 系 -**レビュー確認項目:** -- 3クラス間で ShouldOmitSemivowel のロジックが完全一致していること -- 将来 ShouldOmitSemivowel を変更する場合、3箇所を同時に更新する必要がある旨をコメントに記載すること -- 共有ヘルパー化の是非はセクション6で考察 +声調矢印の基本動作確認。 -### 5.3 そり舌母音・歯茎母音の Misaki 表記 - -IPA と piper-plus で歯茎母音の表記が異なる(`ɹ̩` vs `ɨ`)前例がある。Misaki でこれらの特殊母音をどう表記するかは T01 のマッピングテーブル定義に依存する。 +```csharp +[Theory] +[InlineData("ma1", "ma\u2192")] // ma→ +[InlineData("ma2", "ma\u2197")] // ma↗ +[InlineData("ma3", "ma\u2193")] // ma↓ +[InlineData("ma4", "ma\u2198")] // ma↘ +[InlineData("ma5", "ma")] // 軽声 (矢印なし) +public void Convert_Ma_WithAllTones(string pinyin, string expected) +{ + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); +} +``` -**レビュー確認項目:** -- T01 の `s_retroflexApical` / `s_alveolarApical` が Misaki (Kokoro TTS) の期待する表記と一致していること -- Misaki の中国語音素体系のドキュメントまたはソースコードとの照合 +#### カテゴリ 2: 全声母 × Final.A (21 声母網羅、第 1 声基準) -### 5.4 声調矢印の文字コード +```csharp +// gold.txt より +[InlineData("ba1", "pa\u2192")] // pa→ +[InlineData("pa2", "p\u02B0a\u2197")] // pʰa↗ +[InlineData("fa3", "fa\u2193")] // fa↓ +[InlineData("da4", "ta\u2198")] // ta↘ +[InlineData("ta1", "t\u02B0a\u2192")] // tʰa→ +[InlineData("na2", "na\u2197")] // na↗ +[InlineData("la3", "la\u2193")] // la↓ +[InlineData("ga4", "ka\u2198")] // ka↘ +[InlineData("ka1", "k\u02B0a\u2192")] // kʰa→ +[InlineData("ha2", "xa\u2197")] // xa↗ +[InlineData("ma1", "ma\u2192")] // ma→ +``` -Misaki が使用する声調矢印(↑↓→ 等)の具体的な Unicode コードポイントを T01 マッピングで正確に定義しているか確認すること。IPA の声調文字 (U+02E5-U+02E9) とは全く異なる文字体系になる。 +#### カテゴリ 3: j/q/z/c/zh/ch 合字検証 (U+02A8 / U+02A6 / U+AB67 確認) -### 5.5 Convert のオーバーロード設計 +```csharp +[InlineData("ji1", "\u02A8i\u2192")] // ʨi→ +[InlineData("qi2", "\u02A8\u02B0i\u2197")] // ʨʰi↗ +[InlineData("xi3", "\u0255i\u2193")] // ɕi↓ +[InlineData("zi4", "\u02A6\u0268\u2198")] // ʦɨ↘ +[InlineData("ci1", "\u02A6\u02B0\u0268\u2192")] // ʦʰɨ→ +[InlineData("si2", "s\u0268\u2197")] // sɨ↗ +[InlineData("zhi1", "\uAB67\u0268\u2192")] // ꭧɨ→ +[InlineData("chi2", "\uAB67\u02B0\u0268\u2197")] // ꭧʰɨ↗ +[InlineData("shi3", "\u0282\u0268\u2193")] // ʂɨ↓ +[InlineData("ri4", "\u027B\u0268\u2198")] // ɻɨ↘ +``` -`PinyinToIpa` は `Convert(string)` と `Convert(string, bool)` の2つのオーバーロードを持つ。`PinyinToPiperIpa` は声調マーカーを含めないため `Convert(string)` のみ。Misaki が声調矢印を含める/含めないの両方を必要とするか確認し、オーバーロード構成を決定する。デフォルトは声調矢印付き(`includeTones = true`)とする。 +> **注**: `zi4` は歯茎母音 (U+0268 `ɨ`)。U+02A6 は `ʦ`、U+AB67 は `ꭧ` の +> Kokoro TTS vocab 互換合字 (verified against misaki 0.9.4 via `uv run`)。 -## 6. 一から作り直すとしたら +#### カテゴリ 4: 二重母音 (U+032F なし) -### 6.1 現状の問題 — コピペパターン +```csharp +[InlineData("bai1", "pai\u2192")] // pai→ (NOT pa U+032F i) +[InlineData("mei4", "mei\u2198")] // mei↘ +[InlineData("mao1", "mau\u2192")] // mau→ (NOT mao) +[InlineData("dou4", "tou\u2198")] // tou↘ +[InlineData("lai1", "lai\u2192")] // lai→ +[InlineData("lei1", "lei\u2192")] // lei→ +[InlineData("lao1", "lau\u2192")] // lau→ +[InlineData("lou1", "lou\u2192")] // lou→ +``` -現在の `PinyinToIpa.cs` と `PinyinToPiperIpa.cs` は以下のコードが事実上のコピペである: +> **重要**: 出力に U+032F (COMBINING INVERTED BREVE BELOW) が含まれないことを +> 明示的にアサートするテストも追加: +> +> ```csharp +> [Fact] +> public void Convert_DoesNotIncludeU032F() +> { +> foreach (var syl in new[] { "bai1", "mao2", "dou3", "mei4", "guei1", "jiao1" }) +> { +> var result = PinyinToMisaki.Convert(syl); +> Assert.DoesNotContain("\u032F", result); +> } +> } +> ``` + +#### カテゴリ 5: CVC 声調位置 (声調が中間に入ることを検証) -- `Convert()` メソッドの入口処理(null チェック → ToneConverter → PinyinParser → ConvertSyllable 委譲) -- `ConvertSyllable()` の制御フロー(声母判定 → 半母音省略 → 韻母判定 → そり舌/歯茎分岐 → 声調付与) -- `ShouldOmitSemivowel()` — 3クラスで完全同一のロジック -- `IsRetroflex()` / `IsAlveolar()` — 3クラスで完全同一のロジック +```csharp +[InlineData("man1", "ma\u2192n")] // ma→n (NOT man→) +[InlineData("man2", "ma\u2197n")] // ma↗n +[InlineData("man3", "ma\u2193n")] // ma↓n +[InlineData("man4", "ma\u2198n")] // ma↘n +[InlineData("mang1", "ma\u2192\u014B")] // ma→ŋ +[InlineData("mang2", "ma\u2197\u014B")] // ma↗ŋ +[InlineData("mang3", "ma\u2193\u014B")] // ma↓ŋ +[InlineData("mang4", "ma\u2198\u014B")] // ma↘ŋ +[InlineData("men1", "m\u0259\u2192n")] // mə→n +[InlineData("meng2", "m\u0259\u2197\u014B")] // mə↗ŋ +[InlineData("dong1", "t\u028A\u2192\u014B")] // tʊ→ŋ (U+028A ʊ) +[InlineData("long1", "l\u028A\u2192\u014B")] // lʊ→ŋ +``` -`PinyinToMisaki` を追加すると、これが3重コピーになる。 +> **注**: `dong/long/hong/tong/gong/kong/rong` 系の `Final.Ong` は `Prefix = "ʊ"` +> (U+028A、LATIN SMALL LETTER UPSILON)、`Suffix = "ŋ"` (U+014B)。 +> 旧仕様の `u̯ŋ` は誤り。 -### 6.2 設計案 A: 抽象基底クラス +#### カテゴリ 6: i 系韻母 j 半母音 (ia/ie/iao/iu/ian/in/iang/ing/iong) ```csharp -internal abstract class PinyinConverterBase -{ - // テンプレートメソッド - public TResult Convert(string pinyin, bool includeTones) - { - // 共通: null チェック → ToneConverter → PinyinParser - // → ConvertSyllable(syllable, includeTones) - } +[InlineData("jia1", "\u02A8ja\u2192")] // ʨja→ +[InlineData("jian1", "\u02A8j\u025B\u2192n")] // ʨjɛ→n +[InlineData("jiao1", "\u02A8jau\u2192")] // ʨjau→ +[InlineData("jie1", "\u02A8je\u2192")] // ʨje→ (NOT ʨjɛ!) +[InlineData("jiu1", "\u02A8jou\u2192")] // ʨjou→ +[InlineData("jing1", "\u02A8i\u2192\u014B")] // ʨi→ŋ (j 半母音なし、In と同じ) +[InlineData("jin1", "\u02A8i\u2192n")] // ʨi→n +[InlineData("jiong1", "\u02A8j\u028A\u2192\u014B")] // ʨjʊ→ŋ +[InlineData("jiang1", "\u02A8ja\u2192\u014B")] // ʨja→ŋ +[InlineData("xiong2", "\u0255j\u028A\u2197\u014B")] // ɕjʊ↗ŋ +``` - protected abstract string GetInitial(Initial initial); - protected abstract string GetFinal(Final final_); - protected abstract string GetRetroflexApical(); - protected abstract string GetAlveolarApical(); - protected abstract string GetToneMarker(Tone tone); +#### カテゴリ 7: u 系韻母 w 半母音 (ua/uai/uan/uang/uei/uen/ueng/uo) - // 共通ロジックを基底に集約 - protected static bool ShouldOmitSemivowel(Initial initial, Final final_) { ... } - protected static bool IsRetroflex(Initial initial) { ... } - protected static bool IsAlveolar(Initial initial) { ... } -} +```csharp +[InlineData("gua1", "kwa\u2192")] // kwa→ +[InlineData("guai1", "kwai\u2192")] // kwai→ +[InlineData("guan1", "kwa\u2192n")] // kwa→n +[InlineData("guang1", "kwa\u2192\u014B")] // kwa→ŋ +[InlineData("guo1", "kwo\u2192")] // kwo→ +[InlineData("guei1", "kwei\u2192")] // kwei→ (Misaki uei) +[InlineData("guen1", "kw\u0259\u2192n")] // kwə→n (Misaki uen) +[InlineData("gueng1", "kw\u0259\u2192\u014B")] // kwə→ŋ (Misaki ueng) +[InlineData("lua1", "lwa\u2192")] // lwa→ +[InlineData("luo1", "lwo\u2192")] // lwo→ +[InlineData("luan1", "lwa\u2192n")] // lwa→n ``` -**メリット:** 共通ロジックの一元管理。新しい出力フォーマット追加時にマッピングテーブルだけ定義すればよい。 -**デメリット:** 既存の `PinyinToIpa` / `PinyinToPiperIpa` が static class であるため、インスタンスベースの基底クラスへの移行は破壊的変更。internal なので外部 API には影響しないが、テストの修正が必要。 +> **注**: DotNetG2P の PinyinParser が `guei` を `Initial.G + Final.Ui` にパースする +> 前提でテストを書く。ToneConverter/Parser 層で正規化済みであることを確認すること。 -### 6.3 設計案 B: ジェネリック変換器 + マッピング構造体 +#### カテゴリ 8: ü 系韻母 ɥ 半母音 (üe/üan/ün、lv/nv 経由) ```csharp -internal readonly struct PinyinMapping -{ - public Dictionary Initials { get; } - public Dictionary Finals { get; } - public string RetroflexApical { get; } - public string AlveolarApical { get; } - public string[] ToneMarkers { get; } // Tone enum のインデックスで参照 - public bool IncludeTonesByDefault { get; } -} - -internal static class PinyinConverter -{ - public static string Convert(string pinyin, PinyinMapping mapping, bool includeTones) { ... } - internal static string ConvertSyllable(PinyinSyllable syllable, PinyinMapping mapping, bool includeTones) { ... } -} +[InlineData("jue1", "\u02A8\u0265e\u2192")] // ʨɥe→ +[InlineData("juan1", "\u02A8\u0265\u025B\u2192n")] // ʨɥɛ→n +[InlineData("jun1", "\u02A8y\u2192n")] // ʨy→n (ɥ省略、円唇前舌母音直接) +[InlineData("jv1", "\u02A8y\u2192")] // ʨy→ (Final.V) +[InlineData("jve1", "\u02A8\u0265e\u2192")] // ʨɥe→ +[InlineData("jvan1", "\u02A8\u0265\u025B\u2192n")] // ʨɥɛ→n +[InlineData("jvn1", "\u02A8y\u2192n")] // ʨy→n +[InlineData("lue4", "l\u0265e\u2198")] // lɥe↘ +[InlineData("lve1", "l\u0265e\u2192")] // lɥe→ +[InlineData("lv1", "ly\u2192")] // ly→ +[InlineData("lv4", "ly\u2198")] // ly↘ +[InlineData("nv3", "ny\u2193")] // ny↓ +[InlineData("nve4", "n\u0265e\u2198")] // nɥe↘ ``` -各フォーマットは `PinyinMapping` インスタンスを提供するだけでよい: +> **注**: U+0265 は `ɥ` (LATIN SMALL LETTER TURNED H)、U+025B は `ɛ`、U+0079 は `y` +> (ASCII、円唇前舌狭母音としての IPA 記号)。`jun1` の `y` は 2 番目で、 +> `ʨy→n` の構造は「声母 ʨ + prefix y + tone → + suffix n」となる。 + +#### カテゴリ 9: Y + X (yi/yin/ying/yu/yun/ya/ye/yao/you/yan/yang/yong/yue/yuan) ```csharp -internal static class PinyinToIpa -{ - private static readonly PinyinMapping s_mapping = new PinyinMapping { ... }; - public static string Convert(string pinyin) => PinyinConverter.Convert(pinyin, s_mapping, true); -} +[InlineData("yi1", "i\u2192")] // i→ +[InlineData("yin1", "i\u2192n")] // i→n +[InlineData("ying1", "i\u2192\u014B")] // i→ŋ +[InlineData("yu1", "y\u2192")] // y→ +[InlineData("yun1", "y\u2192n")] // y→n +[InlineData("ya1", "ja\u2192")] // ja→ +[InlineData("ye1", "je\u2192")] // je→ +[InlineData("yao1", "jau\u2192")] // jau→ +[InlineData("you1", "jou\u2192")] // jou→ +[InlineData("yan1", "j\u025B\u2192n")] // jɛ→n +[InlineData("yang1", "ja\u2192\u014B")] // ja→ŋ +[InlineData("yong1", "j\u028A\u2192\u014B")] // jʊ→ŋ +[InlineData("yue1", "\u0265e\u2192")] // ɥe→ +[InlineData("yuan1", "\u0265\u025B\u2192n")] // ɥɛ→n ``` -**メリット:** static class のまま維持できる。既存 API との互換性が高い。マッピングデータと変換ロジックが明確に分離される。 -**デメリット:** `PinyinMapping` 構造体に Dictionary を持つため、構造体としては重い(readonly struct にしても参照型フィールドがある)。class にする方が適切。 +> **注**: `yi/yin/ying` は `omitInitial=true` で prefix が `i` から始まるため、 +> 最終出力に `j` が含まれない (j 省略)。同様に `yu/yun` は `y` から始まる。 +> 一方 `ya/yao/you/yan/yang/yong` は prefix に `j` を含む。 -### 6.4 設計案 C: 共有ヘルパーの分離(最小限のリファクタリング) - -完全同一のロジックのみを共有ヘルパーとして抽出し、各クラスの static 構造は維持する: +#### カテゴリ 10: W + X (wu/wa/wai/wei/wan/wen/wang/weng/wo) ```csharp -internal static class PinyinConversionHelper -{ - public static bool ShouldOmitSemivowel(Initial initial, Final final_) { ... } - public static bool IsRetroflex(Initial initial) { ... } - public static bool IsAlveolar(Initial initial) { ... } -} +[InlineData("wu1", "u\u2192")] // u→ +[InlineData("wa1", "wa\u2192")] // wa→ +[InlineData("wai1", "wai\u2192")] // wai→ +[InlineData("wei1", "wei\u2192")] // wei→ +[InlineData("wan1", "wa\u2192n")] // wa→n +[InlineData("wen1", "w\u0259\u2192n")] // wə→n +[InlineData("wang1", "wa\u2192\u014B")] // wa→ŋ +[InlineData("weng1", "w\u0259\u2192\u014B")] // wə→ŋ +[InlineData("wo1", "wo\u2192")] // wo→ ``` -**メリット:** 最小限の変更で3重コピーの問題を解消。既存テストへの影響がない。 -**デメリット:** ConvertSyllable の制御フロー自体は依然としてコピペのまま。 - -### 6.5 現時点での推奨 - -現在のプロジェクトでは **設計案 C(共有ヘルパー分離)** が最も現実的。理由: +> **注**: `wu1 → u→` は `omitInitial=true` で `w` 省略。 -1. `PinyinToIpa` / `PinyinToPiperIpa` は既に安定稼働中(テスト 936 件通過)であり、大規模リファクタリングのリスクを取る必要がない -2. Misaki 対応で3クラス目が追加される今が共有ヘルパー抽出の自然なタイミング -3. ConvertSyllable の制御フローは各フォーマットで微妙に異なる可能性がある(例: piper-plus は声調マーカーなし、Misaki は矢印、IPA は tone letters)ため、完全な抽象化は過剰 +#### カテゴリ 11: そり舌 (zh/ch/sh/r + i → `ɨ`) -ただし、今後さらに出力フォーマットが増える場合は **設計案 B** への移行を検討すべき。4つ以上のフォーマットが並立する段階が移行の判断基準となる。 +```csharp +[InlineData("zhi1", "\uAB67\u0268\u2192")] // ꭧɨ→ +[InlineData("chi1", "\uAB67\u02B0\u0268\u2192")] // ꭧʰɨ→ +[InlineData("shi1", "\u0282\u0268\u2192")] // ʂɨ→ +[InlineData("ri1", "\u027B\u0268\u2192")] // ɻɨ→ +``` -### 6.6 テスタビリティ観点の追加レビュー +#### カテゴリ 12: 歯茎 (z/c/s + i → `ɨ`) -上記の設計案 A/B/C はいずれもリファクタリング目線(重複排除)に偏っており、**テスタビリティとデバッグ性** の観点での改善余地が残る。以下、QA 視点での追加レビューと設計案を示す。 +```csharp +[InlineData("zi1", "\u02A6\u0268\u2192")] // ʦɨ→ +[InlineData("ci1", "\u02A6\u02B0\u0268\u2192")] // ʦʰɨ→ +[InlineData("si1", "s\u0268\u2192")] // sɨ→ +``` -#### 6.6.1 現状の問題 — internal static パターンのテスト困難性 +> **注**: そり舌と歯茎はいずれも同じ U+0268 `ɨ` (中舌非円唇狭母音) を使用する。 +> 旧仕様の `ɻ̩`/`ɹ̩` (U+027B+U+0329 / U+0279+U+0329) とは異なる。 +> misaki 0.9.4 は両方とも `ɨ` 直接出力。 -`PinyinToMisaki` は `internal static class` として実装予定であり、`Convert` / `ConvertSyllable` ともに internal/public static メソッドとなる。これは既存の `PinyinToIpa` / `PinyinToPiperIpa` と同じパターンだが、以下のテスタビリティ上の問題を抱える。 +#### カテゴリ 13: 感嘆詞 Er (er1-4) -| 問題 | 具体例 | 影響 | -|------|-------|------| -| **モック化不可** | static メソッドはインターフェースを実装できないため、`ChineseG2PEngine` が `PinyinToMisaki.Convert` を直接呼ぶ箇所をユニットテストでモック差し替えできない | エンジン側テストで Misaki 変換の失敗ケースを再現困難 | -| **中間状態の隠蔽** | `ConvertSyllable` 内の声母出力・韻母出力・声調付与の各ステップが `StringBuilder` に逐次追加されるが、途中結果を観察する手段がない | バグ時にどのステップで誤出力が発生したか特定に時間を要する | -| **エラー情報の欠落** | `Convert()` は null チェック/パース失敗時に無言で `string.Empty` を返す設計。呼び出し側はエラー原因(null、空、パース失敗、未定義声母等)を区別できない | 呼び出し側でログ出力・診断ができない。テスト時もエラーパスの識別が困難 | -| **テスト時の可視性確保** | internal メンバーへのアクセスには `InternalsVisibleTo` が必須。テストアセンブリの追加・削除時に忘れやすい | リファクタ後のテスト疎通までに気付きにくい | +```csharp +[InlineData("er1", "\u025A\u2192")] // ɚ→ +[InlineData("er2", "\u025A\u2197")] // ɚ↗ +[InlineData("er3", "\u025A\u2193")] // ɚ↓ +[InlineData("er4", "\u025A\u2198")] // ɚ↘ +``` -#### 6.6.2 設計案 D: IPinyinConverter インターフェース + DI(中〜長期) +> **注**: U+025A `ɚ` (R-colored schwa、R 着色シュワー)。旧仕様の `əɻ` (U+0259+U+027B) とは異なる。 +> Final.Er は特別ケースとして Initial.None での直接 return となる。 -internal static から インターフェース + インスタンスベースへ移行する案。 +#### カテゴリ 14: 感嘆詞 O (Initial.None + Final.O → `ɔ`) ```csharp -public interface IPinyinConverter -{ - string Convert(string pinyin, bool includeTones = true); - PinyinConversionResult ConvertDetailed(string pinyin, bool includeTones = true); -} - -internal sealed class PinyinToMisakiConverter : IPinyinConverter { /* 実装 */ } -internal sealed class PinyinToIpaConverter : IPinyinConverter { /* 実装 */ } -internal sealed class PinyinToPiperIpaConverter : IPinyinConverter { /* 実装 */ } +[InlineData("o1", "\u0254\u2192")] // ɔ→ +[InlineData("o2", "\u0254\u2197")] // ɔ↗ +[InlineData("o3", "\u0254\u2193")] // ɔ↓ +[InlineData("o4", "\u0254\u2198")] // ɔ↘ ``` -**メリット:** -- `ChineseG2PEngine` 側テストで `IPinyinConverter` をモック化できる(Moq / NSubstitute 等) -- コンストラクタ DI により、テスト時に Fake 実装を注入可能 -- 設計案 A / B と自然に組み合わせ可能(基底クラスに IPinyinConverter を実装) -- InternalsVisibleTo が不要になる(public インターフェースを介してテスト) +> **重要**: 単独の "o" (声母なし) は `wo` ではなく `ɔ` (U+0254 LATIN SMALL LETTER OPEN O) +> に変換される。これは misaki 0.9.4 の特別処理 (Initial.None + Final.O のみ) であり、 +> **声母ありの `bo/po/mo/fo` とは全く異なるルート** を通る (カテゴリ 15 参照)。 -**デメリット:** -- 既存 `ChineseG2PEngine` が `PinyinToIpa.Convert()` を直接呼んでいるため、フィールド注入+コンストラクタ変更が必要で破壊的 -- Unity 環境では DI コンテナの追加依存を避ける必要がある → 自前でシングルトンファクトリ `PinyinConverterFactory.GetMisaki()` を返す構造が現実的 -- インスタンス化のオーバーヘッド(ただしシングルトン運用で無視できる) +#### カテゴリ 15: bpmf + o (Initial.B/P/M/F + Final.O → `pwo/pʰwo/mwo/fwo`) -**コスト/メリット評価:** 中規模リファクタ(Engine 側の呼び出し箇所〜10 箇所程度の書き換え)。T02 のスコープ外だが、Mi1 マイルストーン完了後の技術的負債として別チケット化を推奨。 +```csharp +[InlineData("bo1", "pwo\u2192")] // pwo→ +[InlineData("po2", "p\u02B0wo\u2197")] // pʰwo↗ +[InlineData("mo3", "mwo\u2193")] // mwo↓ +[InlineData("fo4", "fwo\u2198")] // fwo↘ +``` -#### 6.6.3 設計案 E: ConvertSyllable の戻り値構造体化(中間状態の公開) +> **注**: `Final.O` の標準 prefix は `"wo"` (空 suffix)。カテゴリ 14 の「単独 o」との +> 分岐は `Initial.None` 判定のみ。b/p/m/f が先行する場合は声母 + `wo` で構築される +> ため、標準ルートを通る (特別ケースではない)。 +> +> 特に混同しがち: `bo1 → pwo→` であって `po→` でも `pɔ→` でもない。 -`ConvertSyllable` の戻り値を単なる `string` から、中間状態を含む構造体に拡張する案。**最小限の変更で最大のデバッグ性向上** が得られる。 +#### カテゴリ 16: エッジケース ```csharp -internal readonly struct PinyinConversionResult +[Fact] +public void Convert_Null_ReturnsEmpty() { - public string Output { get; } // 最終文字列(既存互換) - public string InitialPart { get; } // 声母部分 (例: "m") - public string FinalPart { get; } // 韻母部分 (例: "a") - public string TonePart { get; } // 声調矢印/マーカー部分 (例: "↑") - public bool SemivowelOmitted { get; } // Y/W 省略が発生したか - public bool IsRetroflexApical { get; } // そり舌母音分岐に入ったか - public bool IsAlveolarApical { get; } // 歯茎母音分岐に入ったか - public PinyinSyllable SourceSyllable { get; } // 入力音節(デバッグ用) - - public override string ToString() => Output; // 既存コードとの暗黙変換を期待しない + Assert.Equal(string.Empty, PinyinToMisaki.Convert(null!)); } -``` - -**公開方針:** -- 既存 `Convert(string)` / `Convert(string, bool)` は `string` 戻り値のまま維持(互換性) -- 新規 `ConvertDetailed(string pinyin, bool includeTones)` メソッドを追加し、構造体を返す -- テストコードは `ConvertDetailed` を呼び、`SemivowelOmitted` や `IsRetroflexApical` を直接アサート可能 -**メリット:** -- 「声母が出力された/されなかった」「どの分岐を通ったか」をテストで直接検証できる(現状は最終文字列から逆推論する必要がある) -- 半母音省略の判定ミス(例: `Final.In` なのに j が付く)を `SemivowelOmitted == true` で明示的に検証可能 -- そり舌/歯茎母音の分岐バグを `IsRetroflexApical` / `IsAlveolarApical` で直接検出可能 -- T03 以降の Engine 側テストでも、「全音節に声調矢印が付いたか」といった集計テストが容易 +[Fact] +public void Convert_Empty_ReturnsEmpty() +{ + Assert.Equal(string.Empty, PinyinToMisaki.Convert(string.Empty)); +} -**デメリット:** -- `ConvertSyllable` の内部実装で各部分を個別に追跡する必要がある(StringBuilder 一本書きから分離) -- 構造体のサイズがやや大きめ(string 3 フィールド + bool 3 フィールド)→ ref return や out 引数で回避可能 +[Fact] +public void Convert_Whitespace_ReturnsEmpty() +{ + Assert.Equal(string.Empty, PinyinToMisaki.Convert(" ")); +} -**T02 への組み込み推奨度:** **高**。T02 の実装段階でこの構造を採用すれば、追加コストは小さく、ユニットテストの品質が大きく向上する。 +[Theory] +[InlineData("xyz")] +[InlineData("123")] +[InlineData("zzzzz9")] +[InlineData("ma9")] // 不正声調番号 +[InlineData("bba1")] // 不正声母組合 +public void Convert_InvalidPinyin_ReturnsEmpty(string input) +{ + Assert.Equal(string.Empty, PinyinToMisaki.Convert(input)); +} +``` -#### 6.6.4 設計案 F: エラーハンドリングの粒度強化 +#### カテゴリ 17: Issue #56 参照例 -現状の「失敗時に無言で string.Empty 返却」は、**呼び出し側での診断不能** という重大な欠陥を抱える。以下の TryConvert パターンを追加することを推奨する。 +Issue #56 (Phase 1 / Inv6) で「Kokoro TTS 82M の中国語 vocab と一致する」ことが +検証された代表 2 例の再現: ```csharp -internal enum PinyinConversionError +[Fact] +public void Convert_Ni3_IssueReference() { - None = 0, - NullOrEmpty, // 入力が null または空文字 - ParseFailed, // PinyinParser.TryParse が失敗 - UndefinedInitial, // s_initialMisaki にキーが存在しない - UndefinedFinal, // s_finalMisaki にキーが存在しない - UndefinedToneArrow, // s_toneArrows にインデックスが存在しない + // 「你」 → ni3 → ni↓ + Assert.Equal("ni\u2193", PinyinToMisaki.Convert("ni3")); } -internal static class PinyinToMisaki +[Fact] +public void Convert_Hao3_IssueReference() { - public static bool TryConvert( - string pinyin, - bool includeTones, - out string result, - out PinyinConversionError error) - { - if (string.IsNullOrWhiteSpace(pinyin)) - { - result = string.Empty; - error = PinyinConversionError.NullOrEmpty; - return false; - } - // ... 以下、エラー種別を区別しながら処理 - } - - // 既存 API は TryConvert への薄いラッパーとして維持 - public static string Convert(string pinyin, bool includeTones = true) - => TryConvert(pinyin, includeTones, out var r, out _) ? r : string.Empty; + // 「好」 → hao3 → xau↓ (h→x, ao→au) + Assert.Equal("xau\u2193", PinyinToMisaki.Convert("hao3")); } ``` -**テスト上のメリット:** -- エラーケースごとに `Assert.Equal(PinyinConversionError.ParseFailed, error)` で明示的に検証可能 -- 「パース失敗」と「未定義声母」はどちらも `string.Empty` を返すが、原因が全く異なる → 現状の API ではテストで区別できない -- 将来 T01 のマッピングテーブルに漏れ(例: 新規韻母追加忘れ)があった場合、`UndefinedFinal` として早期検出できる - -**実装上の注意:** -- `s_initialMisaki` / `s_finalMisaki` のルックアップで `TryGetValue` を使用し、失敗時に `UndefinedInitial` / `UndefinedFinal` を返す -- 現在の `Convert` は Dictionary インデクサ (`s_initialIpa[key]`) を使っており、キー欠落時に `KeyNotFoundException` が発生する可能性がある → T02 では `TryGetValue` への移行を検討 - -#### 6.6.5 設計案 G: デバッグ用トレース出力機能 - -開発時・バグ再現時に `ConvertSyllable` の各ステップの実行状態をコンソール/ログに出力する機能。 +#### カテゴリ 18: includeTones=false (声調矢印抑制) ```csharp -internal static class PinyinToMisaki +[Theory] +[InlineData("ma1", "ma")] +[InlineData("ma4", "ma")] +[InlineData("man1", "man")] // ma + (空 tone) + n = man +[InlineData("yi1", "i")] // 半母音省略 + 空 tone +[InlineData("zhi1", "\uAB67\u0268")] // ꭧɨ (tone なし) +[InlineData("er1", "\u025A")] // ɚ (tone なし) +[InlineData("o1", "\u0254")] // ɔ (tone なし) +public void Convert_IncludeTonesFalse(string pinyin, string expected) { - // DEBUG ビルドでのみ有効化されるトレースフック - [Conditional("DEBUG")] - internal static void SetTraceWriter(System.IO.TextWriter? writer) - { - s_traceWriter = writer; - } + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin, includeTones: false)); +} +``` - private static System.IO.TextWriter? s_traceWriter; +### 5.4 テスト総数 - [Conditional("DEBUG")] - private static void Trace(string step, string detail) - { - s_traceWriter?.WriteLine($"[PinyinToMisaki] {step}: {detail}"); - } +| カテゴリ | ケース数 | +|---------|---------| +| 1: 4 声 + 軽声 | 5 | +| 2: 全声母 × Final.A | 11+ | +| 3: 合字検証 | 10 | +| 4: 二重母音 | 8 + U+032F チェック | +| 5: CVC 声調位置 | 12 | +| 6: i 系 j 半母音 | 10 | +| 7: u 系 w 半母音 | 11 | +| 8: ü 系 ɥ 半母音 | 13 | +| 9: Y + X | 14 | +| 10: W + X | 9 | +| 11: そり舌 | 4 | +| 12: 歯茎 | 3 | +| 13: 感嘆詞 Er | 4 | +| 14: 感嘆詞 O | 4 | +| 15: bpmf + o | 4 | +| 16: エッジ | 8 | +| 17: Issue #56 | 2 | +| 18: includeTones=false | 7 | +| **合計** | **約 140 ケース** | + +最終的には **120+ の独立テスト** を目標とする (Theory の InlineData 単位で数える)。 + +### 5.5 テストファイル雛形 + +```csharp +using DotNetG2P.Chinese; +using Xunit; - internal static string ConvertSyllable(PinyinSyllable syllable, bool includeTones) +namespace DotNetG2P.Tests.ChineseG2P +{ + /// + /// Misaki 互換 の単体テスト。 + /// 全期待値は .claude/tmp/misaki-gold.txt に基づき、 + /// misaki==0.9.4 を uv run で直接実行した実測値 (137 件) と照合済み。 + /// + public class PinyinToMisakiConvertTests { - Trace("Input", $"Initial={syllable.Initial}, Final={syllable.Final}, Tone={syllable.Tone}"); - - var sb = new StringBuilder(16); - if (syllable.Initial != Initial.None) - { - if ((syllable.Initial == Initial.Y || syllable.Initial == Initial.W) - && ShouldOmitSemivowel(syllable.Initial, syllable.Final)) - { - Trace("SemivowelOmit", $"{syllable.Initial} omitted for {syllable.Final}"); - } - else - { - var initialStr = s_initialMisaki[syllable.Initial]; - sb.Append(initialStr); - Trace("InitialOut", initialStr); - } - } - // ... 以下同様 - return sb.ToString(); + // カテゴリ 1: 4 声 + 軽声 × ma 系 + [Theory] + [InlineData("ma1", "ma\u2192")] + [InlineData("ma2", "ma\u2197")] + [InlineData("ma3", "ma\u2193")] + [InlineData("ma4", "ma\u2198")] + [InlineData("ma5", "ma")] + public void Convert_Ma_AllTones(string pinyin, string expected) + => Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); + + // ... 他のカテゴリは同様に [Theory] + [InlineData] で列挙 } } ``` -**メリット:** -- `Conditional("DEBUG")` により Release ビルドでは完全に除去される(ゼロコスト) -- テストコードから `StringWriter` を注入してトレース出力をキャプチャし、`Assert.Contains("SemivowelOmit", trace)` のように検証可能 -- バグ報告時に再現コードを渡せば、どのステップで期待外れの挙動が起きたか即座に特定できる - -**デメリット:** -- `[Conditional]` はコンパイル時分岐のため、NuGet パッケージを利用する側(Release ビルド)ではトレースが無効化される → 再現調査には DEBUG ビルドの再実行が必要 -- テストアセンブリ自体は DEBUG ビルドで実行されるため、ユニットテストでのトレース検証は問題なく動作 - -#### 6.6.6 InternalsVisibleTo 設定の確認 - -T02 で `ConvertSyllable` を internal にする場合、既存のテストアセンブリへの可視性を確認する必要がある。 - -**確認事項:** -1. `src/DotNetG2P.Chinese/DotNetG2P.Chinese.csproj` または `AssemblyInfo.cs` に以下の属性があるか: - ```csharp - [assembly: InternalsVisibleTo("DotNetG2P.Tests")] - ``` -2. 既存の `PinyinToIpaTests` / `PinyinToPiperIpaTests` がどのようにテストしているか確認(`public static` を介しているか、`InternalsVisibleTo` を活用しているか) -3. 新規テスト `PinyinToMisakiTests` が `ConvertSyllable(syllable, includeTones)` を直接呼びたい場合、この属性が必須 - -**T02 実装前チェック:** `grep -r "InternalsVisibleTo" src/DotNetG2P.Chinese/` で既存設定を確認し、テストから internal メソッドへの到達経路を事前に保証すること。 - -#### 6.6.7 推奨優先度まとめ - -| 設計案 | T02 への組込 | コスト | メリット | 推奨 | -|--------|------------|--------|---------|------| -| D: IPinyinConverter + DI | スコープ外 | 中 | モック化・疎結合 | 別チケット化 | -| **E: 戻り値構造体化 (ConvertDetailed)** | **T02 内で対応** | **小** | **中間状態のテスト検証** | **強く推奨** | -| **F: TryConvert + エラー列挙** | **T02 内で対応** | **小** | **エラーパスの明示的検証** | **強く推奨** | -| G: Conditional トレース | T02 内で対応可 | 小 | デバッグ時の可視性 | 推奨 | -| D と組み合わせる case | 設計案 B と融合 | 中 | テスト/実装両面で疎結合 | 中期的に検討 | +## 6. 実装懸念・レビュー項目 -**T02 実装時のアクションアイテム:** -1. `ConvertSyllable` の戻り値を `PinyinConversionResult` 構造体化し、`ConvertDetailed` を internal で公開(設計案 E) -2. `TryConvert(out string, out PinyinConversionError)` を追加し、既存 `Convert` はその薄いラッパーとして実装(設計案 F) -3. Dictionary ルックアップを `TryGetValue` に統一し、キー欠落を `UndefinedInitial` / `UndefinedFinal` として返却 -4. `[Conditional("DEBUG")] Trace` フックを配置(設計案 G) -5. `InternalsVisibleTo("DotNetG2P.Tests")` の存在を実装前に確認 -6. ユニットテストで `SemivowelOmitted` / `IsRetroflexApical` / `PinyinConversionError` の各プロパティを明示的にアサートするケースを追加(セクション 4.2 のテスト項目を拡張) +### 6.1 T01 テーブル構造の前提確認 -これらの対応は T02 のスコープ内で実現可能であり、既存の `PinyinToIpa` / `PinyinToPiperIpa` に比べテスタビリティが大きく向上する。同時に、T02 で確立したパターンを既存 2 クラスに逆適用する後続チケット化も視野に入れるべきである。 +T02 実装前に **必ず T01 の以下を確認** すること: -### 6.7 アーキテクトレビュー(統合的まとめ) +- [ ] `s_finalMisaki` が `Dictionary` (タプル) であること +- [ ] `s_yWCompoundMisaki` が `Dictionary<(Initial, Final), (string Prefix, string Suffix, bool OmitInitial)>` であること +- [ ] `s_toneArrows` が `string[]` で 5 要素 `["", "→", "↗", "↓", "↘"]` であること +- [ ] `s_initialMisaki[Initial.Zh]` = `"\uAB67"` (U+AB67、ꭧ) であること (旧 `ʈʂ` ではない) +- [ ] `s_initialMisaki[Initial.J]` = `"\u02A8"` (U+02A8、ʨ) であること +- [ ] `s_initialMisaki[Initial.Z]` = `"\u02A6"` (U+02A6、ʦ) であること +- [ ] テンプレ側で U+032F が事前除去されていること (`s_finalMisaki[Final.Ai] = ("ai", "")` の + prefix に U+032F を含まない) +- [ ] `s_finalMisaki[Final.Ong] = ("\u028A", "\u014B")` (U+028A `ʊ` + U+014B `ŋ`) であること + (旧 `u̯ŋ` ではない) +- [ ] `s_finalMisaki[Final.Ve] = ("\u0265e", "")` (ɥe) であること (旧 `ye` ではない) -既存 6.1〜6.6 は「共通ヘルパー分離」「インターフェース DI」「戻り値構造体化」「エラー列挙」「トレース」と個別の改善案が並列に提示されているが、**T02 の本質的な課題は「4 クラス目のコピペをどう抽象化するか」**である。本節では C# 言語機能の観点から 4 つの統合的アプローチを比較し、T02 の実装時に採用すべき方針を確定する。 +整合していない場合、T02 の実装より先に T01 の rewrite を完了させる必要がある。 -#### 6.7.1 現状整理 — 何がコピペなのか +### 6.2 PinyinParser の挙動確認 -`PinyinToIpa` / `PinyinToPiperIpa` の構造を具体的に分解すると、以下の 4 層にコピペが発生している: +`PinyinToMisaki.Convert` は `PinyinParser.TryParse` に完全依存する。以下を動作確認すること: -| 層 | 内容 | 完全同一か | 差し替え箇所 | -|---|-----|---------|------------| -| L1: 入口処理 | `Convert(string)` の null チェック → `ToneConverter.ToToneMarked` → `PinyinParser.TryParse` → `ConvertSyllable` 委譲 | **完全同一** | なし | -| L2: 分岐制御フロー | `ConvertSyllable` の声母判定 → 半母音省略 → 韻母判定 → そり舌/歯茎分岐 → 声調付与 | **完全同一** | なし | -| L3: 判定ヘルパー | `ShouldOmitSemivowel` / `IsRetroflex` / `IsAlveolar` | **完全同一** | なし | -| L4: マッピングテーブル | `s_initialIpa` / `s_finalIpa` / `s_toneLetters` / `s_retroflexApical` / `s_alveolarApical` | 差異あり | **全行** | +- [ ] `"ma1"` → `PinyinSyllable { Initial.M, Final.A, Tone.First }` +- [ ] `"ma5"` → `PinyinSyllable { Initial.M, Final.A, Tone.Neutral }` +- [ ] `"lv1"` → `PinyinSyllable { Initial.L, Final.V, Tone.First }` (lv → lü 正規化) +- [ ] `"jue1"` → `PinyinSyllable { Initial.J, Final.Ve, Tone.First }` (ju → jü 正規化 + e) +- [ ] `"wang1"` → `PinyinSyllable { Initial.W, Final.Ang, Tone.First }` (Misaki uang とは異なる内部表現) +- [ ] `"yong1"` → `PinyinSyllable { Initial.Y, Final.Ong, Tone.First }` +- [ ] `"er1"` → `PinyinSyllable { Initial.None, Final.Er, Tone.First }` +- [ ] `"o1"` → `PinyinSyllable { Initial.None, Final.O, Tone.First }` -L1〜L3 は **4 クラスでも完全同一** であり、T02 で 3 クラス目(PinyinToMisaki)を実装すると、L1〜L3 が 3 重コピーになる。本質的に差し替えたいのは L4 のみであり、L1〜L3 は「データを引数として受け取る純粋関数」にすべきである。 +### 6.3 U+032F 残留チェック -#### 6.7.2 C# 言語機能ベース 4 方式の比較(T02 視点) +**絶対条件**: `PinyinToMisaki.Convert` の出力は **U+032F を含まない**。 -##### 方式 1: interface ベース(DI/モック化重視) - -既存 6.6.2 の設計案 D で詳述済み。要点は以下: +テストで明示的にアサートする (カテゴリ 4 に含める): ```csharp -public interface IPinyinConverter -{ - string Convert(string pinyin, bool includeTones); -} - -internal sealed class PinyinToMisakiConverter : IPinyinConverter +[Fact] +public void Convert_NeverContainsU032F() { - private static readonly Dictionary s_initials = /* ... */; - - public string Convert(string pinyin, bool includeTones) + var pinyins = new[] + { + "bai1", "mao2", "dou3", "mei4", "guei1", "jiao1", "xiao2", "liu4", + "liao3", "miu1", "niao2", "dui4", "sui1", "tui3", + }; + foreach (var p in pinyins) { - // L1〜L3 の処理を毎回各クラスで書く必要がある - // → default interface methods で共通化可能だが、.NET Standard 2.1 では実装上の制約あり + var result = PinyinToMisaki.Convert(p); + Assert.DoesNotContain("\u032F", result); + Assert.NotEmpty(result); // 変換自体は成功しているべき } } ``` -**致命的な欠点:** interface だけでは L1〜L3 の共通ロジックを基底に置けない(default interface methods は .NET Standard 2.1 で限定的にしか使えず、Unity 2021.2 の IL2CPP との相性も不透明)。結局各実装クラスで L1〜L3 を再実装するため、**コピペ問題が解決しない**。 +### 6.4 特別ケースの優先度 -##### 方式 2: abstract class ベース(既存 A 案の詳細化) +ConvertSyllable のステップ 2 (特別ケース判定) は必ず **以下の順序** で評価すること: -```csharp -internal abstract class PinyinConverterBase -{ - // L4: 差し替えたい部分を abstract で公開 - protected abstract IReadOnlyDictionary InitialMap { get; } - protected abstract IReadOnlyDictionary FinalMap { get; } - protected abstract string[] ToneMarkers { get; } - protected abstract string RetroflexApical { get; } - protected abstract string AlveolarApical { get; } - protected virtual bool IncludeTonesByDefault => true; - - // L1: 入口処理(完全共通、override 不要) - public string Convert(string pinyin) => Convert(pinyin, IncludeTonesByDefault); - - public string Convert(string pinyin, bool includeTones) - { - if (string.IsNullOrEmpty(pinyin)) return string.Empty; - string normalized = ToneConverter.ToToneMarked(pinyin); - if (!PinyinParser.TryParse(normalized, out var syllable)) return string.Empty; - return ConvertSyllable(syllable, includeTones); - } +1. `Initial.None + Final.O` (感嘆詞 ɔ) +2. `Final.Er` (儿化/感嘆詞 ɚ) +3. `(Zh|Ch|Sh|R) + Final.I` (retroflex ɨ) +4. `(Z|C|S) + Final.I` (alveolar ɨ) +5. 標準 Y/W 複合 final ルックアップ → 標準 final ルックアップ - // L2: 分岐制御フロー(完全共通、override 不要) - protected string ConvertSyllable(PinyinSyllable syllable, bool includeTones) - { - var sb = new StringBuilder(16); - - if (syllable.Initial != Initial.None) - { - var skipSemivowel = - (syllable.Initial == Initial.Y || syllable.Initial == Initial.W) - && ShouldOmitSemivowel(syllable.Initial, syllable.Final); - if (!skipSemivowel) sb.Append(InitialMap[syllable.Initial]); - } - - if (syllable.Final != Final.None) - { - sb.Append((syllable.Final, syllable.Initial) switch - { - (Final.I, var i) when IsRetroflex(i) => RetroflexApical, - (Final.I, var i) when IsAlveolar(i) => AlveolarApical, - _ => FinalMap[syllable.Final], - }); - } - - if (includeTones && syllable.Tone != Tone.Neutral) - sb.Append(ToneMarkers[(int)syllable.Tone]); - - return sb.ToString(); - } +相互排他なので順序が実挙動に影響することは少ないが、読み取り時の混乱を避けるため固定する。 - // L3: 判定ヘルパー(完全共通、基底に集約) - protected static bool ShouldOmitSemivowel(Initial initial, Final final_) { /* ... */ } - protected static bool IsRetroflex(Initial initial) { /* ... */ } - protected static bool IsAlveolar(Initial initial) { /* ... */ } -} +### 6.5 Dictionary TryGetValue の使用 -internal sealed class MisakiConverter : PinyinConverterBase -{ - private static readonly Dictionary s_initials = /* ... */; - private static readonly Dictionary s_finals = /* ... */; - private static readonly string[] s_tones = /* ... */; - - protected override IReadOnlyDictionary InitialMap => s_initials; - protected override IReadOnlyDictionary FinalMap => s_finals; - protected override string[] ToneMarkers => s_tones; - protected override string RetroflexApical => "\u027B\u0329"; - protected override string AlveolarApical => "\u0279\u0329"; -} +既存 `PinyinToIpa` は `s_initialIpa[key]` でインデクサを使っているが、T02 では +**全ての lookup で `TryGetValue` を使用** すること: -// 既存 API 互換のファサード -internal static class PinyinToMisaki -{ - private static readonly MisakiConverter s_instance = new MisakiConverter(); - public static string Convert(string pinyin) => s_instance.Convert(pinyin); - public static string Convert(string pinyin, bool includeTones) => s_instance.Convert(pinyin, includeTones); -} +```csharp +// OK (T02) +if (s_initialMisaki.TryGetValue(syllable.Initial, out var init)) + sb.Append(init); + +// NG (旧 PinyinToIpa 様式、KeyNotFoundException のリスク) +sb.Append(s_initialMisaki[syllable.Initial]); ``` -**利点:** -- L1〜L3 の **3 層が完全に基底に集約** され、派生クラスは L4 のマッピングテーブル(5 行の override)のみを書けばよい -- コピペ量が 4 クラス分から 1 クラス分 × 4 マッピングに圧縮される -- 既存 `PinyinToIpa.Convert(...)` 等の公開 API は **ファサード経由で完全維持** できる(破壊的変更なし) -- テスト時は `MisakiConverter` インスタンスを直接生成するか、`PinyinToMisaki` ファサード経由でアクセス -- 派生クラスを `sealed` にすることで、さらなる派生によるバグを防止 +理由: `s_yWCompoundMisaki` と `s_finalMisaki` の両方を参照するため、片方に漏れがあった場合 +`KeyNotFoundException` がスタックの奥で発生し、原因特定が困難になる。 +`TryGetValue` で明示的な empty 返却にすることで、テストで「未定義の組み合わせ」として +検出できる。 -**欠点:** -- インスタンスメソッド呼び出しのオーバーヘッド(シングルトンで毎回同一インスタンスを返すため実質無視可能) -- 既存 `PinyinToPiperIpa.ConvertToPhonemes`(声母/韻母を配列で返すメソッド)のような**クラス固有拡張**が基底では表現できない → 派生クラスに追加メソッドとして実装する必要あり +### 6.6 PinyinToIpa / PinyinToPiperIpa との構造差 -##### 方式 3: record + switch 式ベース(関数型アプローチ) +**PinyinToMisaki は既存 2 クラスと構造を揃えない**。理由: -方式 2 の「クラス継承」を「record によるデータ表現 + 静的純粋関数」に置き換える。T01 の 6.5.2 方式 3 と同じ考え方を T02 側で実装する。 +- 既存 2 クラスは「末尾 tone letters」方式で線形 append +- PinyinToMisaki は「prefix → tone → suffix」で中置構造 -```csharp -// L4: マッピングを不変レコードで表現 -internal sealed record PinyinMappingTable( - IReadOnlyDictionary Initials, - IReadOnlyDictionary Finals, - IReadOnlyList ToneMarkers, - string RetroflexApical, - string AlveolarApical, - bool IncludeTonesByDefault); - -// L1〜L3: 純粋関数として実装(静的クラス) -internal static class PinyinConversionEngine -{ - public static string Convert(string pinyin, PinyinMappingTable table) - => Convert(pinyin, table, table.IncludeTonesByDefault); +ファサード化 (共通エンジン + マッピングテーブル) は Mi1 完了後のリファクタチケット +(別起票) で対応する。T02 のスコープでは **PinyinToMisaki 単独で完結する** 実装とし、 +既存 2 クラスには触れない。 - public static string Convert(string pinyin, PinyinMappingTable table, bool includeTones) - { - if (string.IsNullOrEmpty(pinyin)) return string.Empty; - string normalized = ToneConverter.ToToneMarked(pinyin); - if (!PinyinParser.TryParse(normalized, out var syllable)) return string.Empty; - return ConvertSyllable(syllable, table, includeTones); - } +### 6.7 InternalsVisibleTo - internal static string ConvertSyllable(PinyinSyllable syllable, PinyinMappingTable table, bool includeTones) - { - var sb = new StringBuilder(16); - - if (syllable.Initial != Initial.None - && !(IsSemivowel(syllable.Initial) && ShouldOmitSemivowel(syllable.Initial, syllable.Final))) - { - sb.Append(table.Initials[syllable.Initial]); - } - - if (syllable.Final != Final.None) - { - sb.Append((syllable.Final, syllable.Initial) switch - { - (Final.I, var i) when IsRetroflex(i) => table.RetroflexApical, - (Final.I, var i) when IsAlveolar(i) => table.AlveolarApical, - _ => table.Finals[syllable.Final], - }); - } - - if (includeTones && syllable.Tone != Tone.Neutral) - sb.Append(table.ToneMarkers[(int)syllable.Tone]); - - return sb.ToString(); - } +`ConvertSyllable` は internal static のため、テストアセンブリから直接呼び出すには +`[assembly: InternalsVisibleTo("DotNetG2P.Tests")]` が必要。 - // L3: 判定ヘルパーも純粋関数として static に - private static bool IsSemivowel(Initial i) => i == Initial.Y || i == Initial.W; - private static bool ShouldOmitSemivowel(Initial i, Final f) { /* ... */ } - private static bool IsRetroflex(Initial i) { /* ... */ } - private static bool IsAlveolar(Initial i) { /* ... */ } -} +T02 実装前チェック: -// ファサード: マッピングテーブルを引数として渡すだけ -internal static class PinyinToMisaki -{ - private static readonly PinyinMappingTable s_table = PinyinMappingTables.Misaki; - public static string Convert(string pinyin) => PinyinConversionEngine.Convert(pinyin, s_table); - public static string Convert(string pinyin, bool includeTones) => PinyinConversionEngine.Convert(pinyin, s_table, includeTones); -} -``` +- [ ] `src/DotNetG2P.Chinese/DotNetG2P.Chinese.csproj` または `Properties/AssemblyInfo.cs` に + `InternalsVisibleTo` が設定されていること +- [ ] 設定されていなければ、まず追加してから T02 実装に入ること -**利点:** -- **継承を使わない**: データ(record)とロジック(static 関数)を完全分離 -- `Ipa with { ... }` 構文で Misaki テーブルを差分定義可能(T01 側と整合) -- **switch 式の網羅性チェック**: コンパイラが `(Final, Initial)` タプルのパターンマッチを検証 -- 純粋関数のためテストが極めて容易(副作用なし、状態なし) -- 既存 `static class` 様式との整合性が最も高い -- 将来の source generator 統合時、TSV からの生成ターゲットは `PinyinMappingTable` のインスタンス初期化のみ → 生成コードが最小化 +(大半のケースでは `Convert(string)` public static を介すれば事足りるため、 +`ConvertSyllable` を直接テストするのは 1-2 ケース程度に留める想定) -**欠点:** -- 現状 C# LangVersion の確認が必要(`record` は C# 9.0+) -- 引数 `table` を毎回渡す冗長性(実際はファサード経由のため、ユーザーには見えない) +## 7. 一から作り直すとしたら -##### 方式 4: source generator ベース +旧 T02 のセクション 6「一から作り直すとしたら」は「4 クラス目のコピペをどう抽象化するか」 +という軸で設計案 A-G を提示していたが、**Phase 1-R の新仕様を受けて設計軸そのものが変わる**。 +以下、旧版の教訓を踏まえつつ Phase 1-R 視点で改訂する。 -T01 の F 案と同様。TSV ファイルからコンパイル時に `PinyinMappingTable` のインスタンス初期化コードを生成する。方式 3 との組み合わせで威力を発揮する(方式 3 が「受け皿」となる)。ただし Unity IL2CPP 互換性検証コストが高く、本チケットでは採用見送り。 +### 7.1 Phase 1-R が明らかにした教訓 -#### 6.7.3 4 方式の比較表(T02 視点) +旧 T02 を書いた時点では以下の前提が暗黙に仮定されていた: -| 観点 | 方式 1: interface | 方式 2: abstract class | **方式 3: record+switch** | 方式 4: generator | -|------|----------------|--------------------|-----------------------|------------------| -| L1〜L3 共通化の実現 | **不可**(DIM 制約) | 可(基底に集約) | **可(純粋関数に集約)** | 可 | -| L4 差し替えの書き心地 | 手動 | 手動 override | **`with` 式で差分** | 自動生成 | -| 既存 `internal static` 様式との整合 | 低 | 中(ファサード経由で維持) | **高(static のまま)** | 高 | -| 既存公開 API 互換 | 要書換 | 維持可(ファサード) | **維持可(ファサード)** | 維持可 | -| 派生クラス固有拡張 (`ConvertToPhonemes`) | 可(インスタンスメソッド追加) | 可(インスタンスメソッド追加) | **可(静的メソッド追加)** | 可 | -| コンパイル時型安全性 | 中 | 中(override 漏れ) | **高(switch 網羅)** | 高 | -| テスト容易性 (L1〜L3 単体テスト) | 中(モック可) | 中(基底のテストが必要) | **高(純粋関数)** | 中 | -| ランタイムコスト | 中(仮想呼び出し) | 中(仮想呼び出し) | **低(static 呼び出し)** | 最低 | -| リファクタ規模 (3 クラス同時改修) | 大 | 中 | **中** | 大 | -| Unity/IL2CPP 互換 | 高 | 高 | **高** | 要検証 | -| 追加形式 5 つ目の工数 | 中 | **小** | **小** | 最小 | +1. 「Misaki は IPA の変種なので、PinyinToIpa の構造を流用できる」 +2. 「声調は末尾付与が自然」 +3. 「Y/W の半母音省略は声母マップを空文字化すれば済む」 +4. 「U+032F は IPA 精密表記なので含めるべき」 -**方式 3(record + switch 式)が総合的に最有力。** +これらは **いずれも誤り** であることが `uv run misaki==0.9.4` の実測で判明した。 -#### 6.7.4 既存「設計案 E(戻り値構造体化)」との統合 +| 誤った前提 | Phase 1-R での実測 | +|----------|----------------| +| IPA の変種 | Kokoro TTS 専用の独自音素体系 (U+AB67 ꭧ、U+02A8 ʨ、U+02A6 ʦ、U+0254 ɔ 等、vocab は Kokoro 82M に縛られる) | +| 末尾 tone | 韻母の核母音の後、後続子音の前に挿入 (`ma→n`) | +| 声母マップ空文字化 | 複合 final テーブル (24 エントリ) による lookup が必須 | +| U+032F 含める | misaki 出力は U+032F を含まない (`bai` であって `ba u̯ i` でも `bai̯` でもない) | -既存 6.6.3 で提案されている `PinyinConversionResult` 構造体(中間状態を含む戻り値)は、方式 3 と自然に統合できる。 +教訓: **「既存の類似クラスの構造を流用する」前に、必ずソース実装を実測確認すること**。 +misaki のようにドキュメント化が限定的なライブラリでは、Python 実装を `uv run` で +直接実行してサンプル入出力を取得するのが最も確実である。 -```csharp -internal readonly record struct PinyinConversionResult( - string Output, - string InitialPart, - string FinalPart, - string TonePart, - bool SemivowelOmitted, - bool IsRetroflexApical, - bool IsAlveolarApical, - PinyinSyllable SourceSyllable) -{ - public static implicit operator string(PinyinConversionResult r) => r.Output; -} +### 7.2 今後の設計判断に反映すべきこと -internal static class PinyinConversionEngine -{ - // 既存互換の string 戻り値版 - public static string Convert(string pinyin, PinyinMappingTable table, bool includeTones) - => ConvertDetailed(pinyin, table, includeTones).Output; +Phase 1-R で確立したパターンを Mi1 の後続チケット (T03-T06) に引き継ぐ際の指針: - // 詳細戻り値版 - public static PinyinConversionResult ConvertDetailed(string pinyin, PinyinMappingTable table, bool includeTones) - { - // ... 中間状態を追跡しつつ変換 - } -} -``` +1. **仕様確認は「ドキュメント」ではなく「実装実行」で行う** + - 参照: `.claude/tmp/misaki-gold.txt` (137 件、`uv run` で生成済み) + - 新しい出力形式を追加する場合は、必ず同様の gold standard を生成してから仕様書を書く -`readonly record struct` を使うことで、値型の軽量性を保ちつつ `with` 式での部分更新や、コンパイラによる等価性実装を享受できる。 +2. **マッピングテーブルの構造を「単一 string」から「タプル (prefix, suffix)」に抽象化** + - 将来 IPA 側も声調中置が必要になった場合、同じ構造を使える + - T01 で `s_finalMisaki` が `Dictionary` になっているため、 + `PinyinToIpa` 側の rewrite 時に同じ形式を採用できる -#### 6.7.5 T02 実装時の統合的推奨 +3. **Y/W 処理を「ヘルパー関数」から「テーブル lookup」に移行** + - `ShouldOmitSemivowel` のような分岐ヘルパーは「例外の例外」を生みやすい + - 複合 final テーブルは **データとして全ケースが列挙可能** で、仕様書の表と 1 対 1 対応 -本チケット既存セクション 6.5(現時点での推奨)では「設計案 C(共有ヘルパー分離)」が推奨されているが、アーキテクトレビューの結果、**以下の修正推奨を提示する**。 +4. **既存 2 クラス (`PinyinToIpa`/`PinyinToPiperIpa`) に触らない** + - T02 のスコープでは「Misaki 単独で完結」とする + - テスト 936 件を壊さないために、ファサード化リファクタは Mi1 完了後に別チケット化 -##### 短期(T02 実装時): 設計案 C を採用しつつ、方式 3 への移行準備を整える +### 7.3 将来のリファクタ候補 (Mi1 完了後) -**やること:** +以下は Mi1 完了後に別チケット化することを推奨: -1. **L3 判定ヘルパーを `Internal/PinyinConversionHelper.cs` に抽出**(既存案 C) - - `ShouldOmitSemivowel`, `IsRetroflex`, `IsAlveolar` を `internal static` メソッドとして配置 - - `PinyinToIpa` / `PinyinToPiperIpa` / `PinyinToMisaki` の 3 クラスから呼び出す - - 既存 2 クラスから重複定義を削除(T02 スコープ内で軽微な書換) +- **新チケット: 「PinyinConverter 共通抽象化リファクタ」** + - `PinyinMappingTable` record の導入 (旧 T02 方式 3 をベース) + - `PinyinConversionEngine.Convert(syllable, table, includeTones)` の抽出 + - `PinyinToIpa`/`PinyinToPiperIpa`/`PinyinToMisaki` をファサードに変更 + - ただし prefix/suffix 構造と末尾 tone 構造の両方をサポートする必要があるため、 + `PinyinMappingTable` は `bool TonePosition: Suffix/Middle` のようなフラグを持つ設計になる -2. **`PinyinToMisaki.Convert` / `ConvertSyllable` は既存 2 クラスと構造を**完全に揃える** - - エントリの順序、コメント様式、変数名、`StringBuilder` の初期容量まで統一 - - 将来 `PinyinConversionEngine` に抽出する際、3 クラスの `ConvertSyllable` 実装が文字単位で一致するよう書く - - 差異は L4(マッピングテーブル)の参照名のみに集約 +- **新チケット: 「テストケース自動生成」** + - `.claude/tmp/misaki-gold.txt` を入力として xUnit `[InlineData]` を自動生成するスクリプト + - `tools/DotNetG2P.MisakiGoldToTests/` ディレクトリに配置 + - misaki の新バージョン (0.10.x 等) が出た際に再生成できるようにする -3. **Dictionary ルックアップを `TryGetValue` に統一**(既存案 F の部分採用) - - `s_initialMisaki[key]` ではなく `s_initialMisaki.TryGetValue(key, out var value)` を使用 - - キー欠落時のフォールバック(例: 空文字返却 or `KeyNotFoundException` 再スロー)を明示化 - - 既存 2 クラスも将来的に同じ方式に揃える +- **新チケット: 「IPA 音素体系の vocab 互換性検証」** + - Kokoro 82M の Chinese vocab ファイル (`kokoro-chinese.json` 等) と + `s_initialMisaki`/`s_finalMisaki`/`s_yWCompoundMisaki` の全出力候補を + 突合するユニットテスト + - 1 文字でも vocab 外に漏れていれば即時失敗させる -**やらないこと:** +## 8. 後続タスクへの引継ぎ (T03: ChineseG2PEngine への ToMisaki API 追加) -- 設計案 E(`PinyinConversionResult` 構造体化)は T02 スコープ外にする。理由: 3 クラス分のリファクタ負荷が大きく、Mi1 マイルストーン全体を遅延させる -- 設計案 F(完全な `TryConvert` エラー列挙)は見送る。理由: エラーパスの詳細化は ChineseG2PEngine レベルで扱うほうが自然で、変換クラス単体での必要性が低い +T03 担当エージェントに伝えるべき情報: -##### 中期(Mi1 完了後、別チケット化): 方式 3(record + switch 式)への移行 +### 8.1 利用可能なメソッド -**新規チケット「PinyinConverter 共通抽象化リファクタ」を起票し、以下を実施:** +- `PinyinToMisaki.Convert(string pinyin)` — 声調矢印あり、デフォルト +- `PinyinToMisaki.Convert(string pinyin, bool includeTones)` — 声調矢印制御可 +- `PinyinToMisaki.ConvertSyllable(PinyinSyllable, bool)` — パース済み音節を直接渡す用 -1. `PinyinMappingTable` record の導入 -2. `PinyinConversionEngine.Convert(syllable, table, includeTones)` の抽出 -3. `PinyinMappingTables.Ipa` / `PiperIpa` / `Misaki` の定義(`Ipa with { ... }` 構文で差分記述) -4. `PinyinToIpa` / `PinyinToPiperIpa` / `PinyinToMisaki` をファサードに変更(既存 public API は維持) -5. `PinyinToPiperIpa.ConvertToPhonemes` は `PinyinConversionEngine.ConvertToPhonemes(syllable, table)` に一般化 -6. 既存 936 件 + Misaki 追加分のテストが全件通過することで安全性を保証 -7. `PinyinConversionResult` 構造体化(既存案 E)も同時実施し、テスタビリティを向上 +### 8.2 ChineseG2PEngine からの使用例 -##### 長期(5 形式目追加時): 方式 4(source generator)への移行を再検討 +```csharp +// T03 での実装イメージ +public string ToMisaki(string text) => ToMisaki(text, includeTones: true); -- Unity Roslyn Generator サポート状況を再確認 -- TSV 方式(既存案 D)と組み合わせ、マスターデータの一元管理を実現 -- 方式 3 の `PinyinMappingTable` を受け皿として活用 +public string ToMisaki(string text, bool includeTones) +{ + return RunPipeline(text, p => PinyinToMisaki.Convert(p, includeTones)); +} + +public string[] ToMisakiBatch(string[] texts, bool includeTones = true) + => BatchConversionHelper.Convert(texts, t => ToMisaki(t, includeTones)); +``` -#### 6.7.6 アーキテクチャ上の本質的な教訓 +既存の `ToIpa` / `ToPiperIpa` と同一パターンでよい。 -本レビューで明確化された本質的な教訓を、今後の設計判断に活用するために記録する: +### 8.3 バッチ API について -1. **「コピペ」は一層ではない。L1〜L4 の各層で異なる抽象化戦略が必要** - - L1(入口処理)と L2(分岐制御)は純粋関数化が最適 - - L3(判定ヘルパー)は static ヘルパークラスが最適 - - L4(マッピングデータ)は record + `with` 式が最適 +`ConvertBatch` に相当するメソッドは T02 のスコープ外。ChineseG2PEngine 側の +`BatchConversionHelper` を使って T03 内で実装する想定。 -2. **`internal static class` パターンは「値と関数の分離」で抽象化できる** - - インスタンスベース(interface/abstract class)への移行は必須ではない - - データ(record)と関数(static)を分離することで、static 様式を保ったまま抽象化できる +### 8.4 ConvertToPhonemes 未実装 -3. **`PinyinToZhuyin` を抽象化の対象から除外する判断** - - 構造が本質的に異なる(`string` キー、enum 非経由) - - 全てを「1 つのパターンで統一」する必要はない - - 「IPA ファミリ」という部分集合に対する抽象化にとどめるべき +`PinyinToPiperIpa` にある `ConvertToPhonemes()` (声母と韻母を分離した配列を返す) に +相当するものは T02 のスコープ外。Misaki で音素単位処理や Prosody 処理が必要な場合は +別タスクを起票すること (現状、Mi1 の範囲では不要と判断)。 -4. **既存テストが 936 件ある状況でのリファクタは「ファサード保持」が鉄則** - - 内部構造は大胆に変更してよいが、public/internal の API 面は維持する - - ファサードパターンを挟むことで、テストの書き換えを最小化できる +### 8.5 `includeTones=false` 時の出力特性 -5. **段階的移行の工程表を明示することで、短期・中期・長期の意思決定を整合させる** - - 「今はやらないが、将来やる」という意思を明示することで、現在の設計判断(コピペ容認)が正当化される - - 「将来の移行を機械的に可能にする」ための現在の制約(エントリ順序統一、命名規則統一)を明示的に課す +- 軽声 (`ma5`) と `includeTones=false` 指定時 (`ma1` with `includeTones=false`) の両方で + `"ma"` が返る (区別不可) +- CVC の場合 (`man1` with `includeTones=false`) は `"man"` が返る (prefix + "" + suffix) +- 感嘆詞 (`o1` with `includeTones=false`) は `"ɔ"` が返る +- 特別ケース判定は `includeTones` と独立して動作するため、矢印のみ抑制される -## 7. 後続タスクへの連絡事項 +T03 の ChineseG2PEngine API では、`ToMisaki(text, includeTones)` のデフォルトを +`true` にすること (既存 `ToIpa` と整合)。 -T03(ChineseG2PEngine への ToMisaki API 追加)に伝えるべき情報: +### 8.6 期待値マスターデータ -1. **Convert のシグネチャ**: `PinyinToMisaki.Convert(string pinyin)` と `PinyinToMisaki.Convert(string pinyin, bool includeTones)` の2つのオーバーロードが利用可能。前者は `includeTones = true` で委譲する -2. **ConvertSyllable の可視性**: `internal static` であるため、同一アセンブリ (`DotNetG2P.Chinese`) 内から直接呼び出し可能。ChineseG2PEngine の `RunPipeline` ラムダから `p => PinyinToMisaki.Convert(p)` の形で使用できる(`PinyinToIpa.Convert(p, includeTones)` と同一パターン) -3. **声調制御**: `includeTones` パラメータで声調矢印の有無を制御可能。ChineseG2PEngine の `ToMisaki(string text)` / `ToMisaki(string text, bool includeTones)` オーバーロードに対応付けること -4. **ConvertToPhonemes 未実装**: `PinyinToPiperIpa` にある `ConvertToPhonemes()` メソッド(声母と韻母を分離した配列を返す)に相当するものは T02 のスコープ外。Misaki で音素単位処理やProsody処理が必要な場合は別途タスクを起票すること -5. **バッチ API**: `ConvertBatch` 等のバッチ変換 API は T02 のスコープ外。ChineseG2PEngine 側で `BatchConversionHelper` を使用するパターン(既存の `ToIpaBatch` 等と同一)で T03 内に実装すること +`.claude/tmp/misaki-gold.txt` は T02 テストと T03/T04 統合テストの両方で参照する。 +misaki の新バージョンが出た場合は `tools/refresh_misaki_gold.ps1` (仮) を作成して +再生成する運用とするのが望ましい (Mi1 完了後の課題)。 -## 8. 紐づけ +## 紐づけ -- **マイルストーン**: Mi1(Misaki 互換中国語出力) -- **依存**: T01(マッピングテーブル定義)— T01 の `s_initialMisaki`, `s_finalMisaki`, `s_toneArrows`, `s_retroflexApical`, `s_alveolarApical` が定義済みであること -- **後続**: T03(ChineseG2PEngine API 追加)— Convert メソッドが完成していることが前提 +- **マイルストーン**: Mi1 (Misaki 互換中国語出力) +- **依存**: T01 (マッピングテーブル定義、Phase 1-R 版 rewrite 済みであること) +- **後続**: T03 (ChineseG2PEngine API 追加) +- **参照仕様**: `.claude/tmp/misaki-spec.md` (verified against misaki 0.9.4 via uv run) +- **参照 gold**: `.claude/tmp/misaki-gold.txt` (137 件実測値) +- **関連 Issue**: #56 (Phase 1 Mi1 準備、Kokoro vocab 互換性検証) diff --git a/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs b/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs index 7a95510..b26bb72 100644 --- a/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs +++ b/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs @@ -6,16 +6,24 @@ namespace DotNetG2P.Chinese /// /// Misaki 互換のピンイン→IPA変換。 /// Kokoro TTS の G2P フロントエンド と同一の音素表記を使用する。 - /// 主な差異: + /// Phase 1-R verified via uv misaki 0.9.4 (実測 137 件、.claude/tmp/misaki-gold.txt 参照)。 + /// 主な特徴: /// - /// 破擦音 j/q/z/c は 1 文字合字 (ʨ/ʨʰ/ʦ/ʦʰ) を使用 - /// 二重母音の滑り音に非音節化符号 (U+032F) を付与 (ai̯, au̯, ei̯, ou̯ …) + /// 破擦音 j/q/z/c/zh/ch は合字 (ʨ/ʨʰ/ʦ/ʦʰ/ꭧ/ꭧʰ) を使用 + /// 韻母は Prefix + tone + Suffix 方式 (An=("a","n") → "a" + 声調 + "n") /// 声調は IPA tone letters ではなく矢印記号 (→ ↗ ↓ ↘) を使用 + /// 二重母音は U+032F を付けず、template 側で事前除去済み (ai, au, ei, ou 等) + /// retroflex/alveolar apical は直接 ɨ (U+0268) として出力 + /// Y/W 複合韻母は (Initial, Final) ペアから独立テーブルで lookup /// + /// 仕様参照: .claude/tmp/misaki-spec.md /// internal static class PinyinToMisaki { - // 声母→IPAマッピング(Misaki準拠) + // ──────────────────────────────────────────────────────────── + // 声母マッピング (21 エントリ、Misaki spec 準拠) + // Y/W は compound final 層で処理するのでエントリなし + // ──────────────────────────────────────────────────────────── private static readonly Dictionary s_initialMisaki = new Dictionary { [Initial.B] = "p", @@ -29,63 +37,67 @@ internal static class PinyinToMisaki [Initial.G] = "k", [Initial.K] = "k\u02B0", // kʰ [Initial.H] = "x", - [Initial.J] = "\u02A8", // ʨ (Misaki: 合字、標準IPAは tɕ) + [Initial.J] = "\u02A8", // ʨ (合字、confirmed by uv run) [Initial.Q] = "\u02A8\u02B0", // ʨʰ [Initial.X] = "\u0255", // ɕ - [Initial.Zh] = "\u0288\u0282", // ʈʂ - [Initial.Ch] = "\u0288\u0282\u02B0", // ʈʂʰ + [Initial.Zh] = "\uAB67", // ꭧ (Misaki 合字、NOT ʈʂ、confirmed by uv run) + [Initial.Ch] = "\uAB67\u02B0", // ꭧʰ [Initial.Sh] = "\u0282", // ʂ [Initial.R] = "\u027B", // ɻ - [Initial.Z] = "\u02A6", // ʦ (Misaki: 合字、標準IPAは ts) + [Initial.Z] = "\u02A6", // ʦ (合字) [Initial.C] = "\u02A6\u02B0", // ʦʰ [Initial.S] = "s", - [Initial.Y] = "j", - [Initial.W] = "w", + // Y, W は意図的にエントリなし (compound final 層で処理) }; - // 韻母→IPAマッピング(Misaki準拠) - // 二重母音の滑り音には非音節化符号 U+032F (COMBINING INVERTED BREVE BELOW) を付与する。 - private static readonly Dictionary s_finalMisaki = new Dictionary + // ──────────────────────────────────────────────────────────── + // 韻母テンプレート (Prefix + Tone + Suffix 方式、36 エントリ) + // 声調は Prefix と Suffix の間に挿入される + // U+032F (非音節化符号) は事前に strip 済み + // ──────────────────────────────────────────────────────────── + private static readonly Dictionary s_finalMisaki = new Dictionary { - [Final.A] = "a", - [Final.O] = "o", - [Final.E] = "\u0264", // ɤ - [Final.Ai] = "ai\u032F", // ai̯ (標準IPA: aɪ) - [Final.Ei] = "ei\u032F", // ei̯ (標準IPA: eɪ) - [Final.Ao] = "au\u032F", // au̯ (標準IPA: aʊ) - [Final.Ou] = "ou\u032F", // ou̯ (標準IPA: oʊ) - [Final.An] = "an", - [Final.En] = "\u0259n", // ən - [Final.Ang] = "a\u014B", // aŋ - [Final.Eng] = "\u0259\u014B", // əŋ - [Final.Ong] = "u\u032F\u014B", // u̯ŋ (標準IPA: ʊŋ) - [Final.I] = "i", - [Final.Ia] = "ia", - [Final.Ie] = "i\u025B", // iɛ - [Final.Iao] = "iau\u032F", // iau̯ - [Final.Iu] = "iou\u032F", // iou̯ - [Final.Ian] = "i\u025Bn", // iɛn - [Final.In] = "in", - [Final.Iang] = "ia\u014B", // iaŋ - [Final.Ing] = "i\u014B", // iŋ - [Final.Iong] = "iu\u032F\u014B", // iu̯ŋ (標準IPA: iʊŋ) - [Final.U] = "u", - [Final.Ua] = "ua", - [Final.Uo] = "uo", - [Final.Uai] = "uai\u032F", // uai̯ - [Final.Ui] = "uei\u032F", // uei̯ - [Final.Uan] = "uan", - [Final.Un] = "u\u0259n", // uən - [Final.Uang] = "ua\u014B", // uaŋ - [Final.Ueng] = "u\u0259\u014B", // uəŋ - [Final.V] = "y", - [Final.Ve] = "y\u025B", // yɛ - [Final.Van] = "yan", - [Final.Vn] = "yn", - [Final.Er] = "\u0259\u027B", // əɻ + [Final.A] = ("a", ""), + [Final.O] = ("wo", ""), // bpmf + o → "pwo" etc. + [Final.E] = ("\u0264", ""), // ɤ + [Final.Ai] = ("ai", ""), // U+032F strip 済み + [Final.Ei] = ("ei", ""), + [Final.Ao] = ("au", ""), + [Final.Ou] = ("ou", ""), + [Final.An] = ("a", "n"), // 声調が中間 + [Final.En] = ("\u0259", "n"), // ən + [Final.Ang] = ("a", "\u014B"), // aŋ + [Final.Eng] = ("\u0259", "\u014B"), // əŋ + [Final.Ong] = ("\u028A", "\u014B"), // ʊŋ (U+028A、旧 u̯ は誤り) + [Final.I] = ("i", ""), + [Final.Ia] = ("ja", ""), + [Final.Ie] = ("je", ""), // je (標準IPA の iɛ とは違う、NOT jɛ) + [Final.Iao] = ("jau", ""), // strip 済み + [Final.Iu] = ("jou", ""), // Misaki "iou"、strip 済み + [Final.Ian] = ("j\u025B", "n"), // jɛn、声調が中間 + [Final.In] = ("i", "n"), + [Final.Iang] = ("ja", "\u014B"), // jaŋ + [Final.Ing] = ("i", "\u014B"), // iŋ + [Final.Iong] = ("j\u028A", "\u014B"), // jʊŋ + [Final.U] = ("u", ""), + [Final.Ua] = ("wa", ""), + [Final.Uo] = ("wo", ""), + [Final.Uai] = ("wai", ""), // strip 済み + [Final.Ui] = ("wei", ""), // Misaki "uei"、strip 済み + [Final.Uan] = ("wa", "n"), + [Final.Un] = ("w\u0259", "n"), // wən (Misaki "uen") + [Final.Uang] = ("wa", "\u014B"), // waŋ + [Final.Ueng] = ("w\u0259", "\u014B"), // wəŋ + [Final.V] = ("y", ""), + [Final.Ve] = ("\u0265e", ""), // ɥe (ɥ=U+0265、NOT y) + [Final.Van] = ("\u0265\u025B", "n"), // ɥɛn (NOT yan) + [Final.Vn] = ("y", "n"), // yn + [Final.Er] = ("\u025A", ""), // ɚ (U+025A、NOT əɻ) }; - // 声調→矢印記号マッピング(Misaki準拠) + // ──────────────────────────────────────────────────────────── + // 声調矢印 (5 エントリ、Misaki spec 準拠) + // ──────────────────────────────────────────────────────────── private static readonly string[] s_toneArrows = new string[] { "", // Neutral (0) - 軽声は声調なし @@ -95,11 +107,52 @@ internal static class PinyinToMisaki "\u2198", // Fourth (4) ↘ 去声 }; - // zh/ch/sh/r + i のそり舌母音 ɻ̩ (PinyinToIpa と同一) - private static readonly string s_retroflexApical = "\u027B\u0329"; // ɻ̩ + // ──────────────────────────────────────────────────────────── + // Y/W 複合韻母マッピング (23 エントリ、Misaki spec の Y/W 表から) + // DotNetG2P の PinyinParser は "wang" を Initial.W + Final.Ang に parse する + // (Misaki の "uang" とは違う構造) ので、このペアから独立テーブルで lookup する。 + // Value: (Prefix, Suffix, OmitInitial) + // OmitInitial=true の場合、W/Y は省略される (yi→i, wu→u 等) + // ──────────────────────────────────────────────────────────── + private static readonly Dictionary<(Initial, Final), (string Prefix, string Suffix, bool OmitInitial)> s_yWCompoundMisaki = new Dictionary<(Initial, Final), (string, string, bool)> + { + // Y + X (14 エントリ) + [(Initial.Y, Final.A)] = ("ja", "", false), // ya → ia + [(Initial.Y, Final.An)] = ("j\u025B", "n", false), // yan → ian → jɛn + [(Initial.Y, Final.Ang)] = ("ja", "\u014B", false), // yang → iang → jaŋ + [(Initial.Y, Final.Ao)] = ("jau", "", false), // yao → iao → jau + [(Initial.Y, Final.E)] = ("je", "", false), // ye → ie → je + [(Initial.Y, Final.I)] = ("i", "", true), // yi → i (j 省略) + [(Initial.Y, Final.In)] = ("i", "n", true), // yin → in (j 省略) + [(Initial.Y, Final.Ing)] = ("i", "\u014B", true), // ying → iŋ (j 省略) + [(Initial.Y, Final.Ong)] = ("j\u028A", "\u014B", false), // yong → iong → jʊŋ + [(Initial.Y, Final.Ou)] = ("jou", "", false), // you → iu (iou) → jou + [(Initial.Y, Final.V)] = ("y", "", true), // yu → ü → y (ɥ 省略) + [(Initial.Y, Final.Ve)] = ("\u0265e", "", false), // yue → üe → ɥe + [(Initial.Y, Final.Van)] = ("\u0265\u025B", "n", false), // yuan → üan → ɥɛn + [(Initial.Y, Final.Vn)] = ("y", "n", true), // yun → ün → yn (ɥ 省略) + + // W + X (9 エントリ) + [(Initial.W, Final.A)] = ("wa", "", false), // wa → ua + [(Initial.W, Final.Ai)] = ("wai", "", false), // wai + [(Initial.W, Final.An)] = ("wa", "n", false), // wan → uan + [(Initial.W, Final.Ang)] = ("wa", "\u014B", false), // wang → uang → waŋ + [(Initial.W, Final.Ei)] = ("wei", "", false), // wei → ui (uei) + [(Initial.W, Final.En)] = ("w\u0259", "n", false), // wen → un (uen) → wən + [(Initial.W, Final.Eng)] = ("w\u0259", "\u014B", false), // weng → wəŋ + [(Initial.W, Final.O)] = ("wo", "", false), // wo → uo + [(Initial.W, Final.U)] = ("u", "", true), // wu → u (w 省略) + }; - // z/c/s + i の歯茎母音 ɹ̩ (PinyinToIpa と同一) - private static readonly string s_alveolarApical = "\u0279\u0329"; // ɹ̩ + // ──────────────────────────────────────────────────────────── + // そり舌/歯茎母音の直接マッピング (retroflex/alveolar apical → ɨ) + // retroflex (ɻ̩) / alveolar (ɹ̩) の代わりに Misaki は直接 ɨ を使用する + // ──────────────────────────────────────────────────────────── + private const string s_apicalMisaki = "\u0268"; // ɨ + + // ──────────────────────────────────────────────────────────── + // アクセサ (internal、テスト用) + // ──────────────────────────────────────────────────────────── /// 声母 Initial に対応する Misaki 互換 IPA 文字列を返す(テスト・検証用)。 internal static string GetInitialMisaki(Initial initial) @@ -107,10 +160,10 @@ internal static string GetInitialMisaki(Initial initial) return s_initialMisaki.TryGetValue(initial, out var value) ? value : string.Empty; } - /// 韻母 Final に対応する Misaki 互換 IPA 文字列を返す(テスト・検証用)。 - internal static string GetFinalMisaki(Final final_) + /// 韻母 Final に対応する Misaki 互換 (Prefix, Suffix) タプルを返す(テスト・検証用)。 + internal static (string Prefix, string Suffix) GetFinalMisaki(Final final_) { - return s_finalMisaki.TryGetValue(final_, out var value) ? value : string.Empty; + return s_finalMisaki.TryGetValue(final_, out var value) ? value : (string.Empty, string.Empty); } /// 声調 Tone に対応する Misaki 互換矢印記号を返す(テスト・検証用)。 @@ -120,11 +173,18 @@ internal static string GetToneArrow(Tone tone) return (index >= 0 && index < s_toneArrows.Length) ? s_toneArrows[index] : string.Empty; } - /// zh/ch/sh/r + i で使用するそり舌母音を返す(テスト・検証用)。 - internal static string GetRetroflexApical() => s_retroflexApical; + /// Y/W 複合韻母マッピングのエントリを取得する(テスト・検証用)。 + internal static bool TryGetYWCompound(Initial initial, Final final_, out (string Prefix, string Suffix, bool OmitInitial) result) + { + return s_yWCompoundMisaki.TryGetValue((initial, final_), out result); + } + + /// retroflex/alveolar apical に使用する ɨ を返す(テスト・検証用)。 + internal static string GetApicalMisaki() => s_apicalMisaki; - /// z/c/s + i で使用する歯茎母音を返す(テスト・検証用)。 - internal static string GetAlveolarApical() => s_alveolarApical; + // ──────────────────────────────────────────────────────────── + // 変換メソッド + // ──────────────────────────────────────────────────────────── /// /// 声調記号付きピンインを Misaki 互換 IPA 表記に変換する(声調矢印付き)。 @@ -157,56 +217,98 @@ public static string Convert(string pinyin, bool includeTones) /// /// PinyinSyllable を Misaki 互換 IPA 表記に変換する。 + /// パイプライン: + /// + /// 声調矢印決定 + /// 特別ケース判定 (単独 ō, Er, retroflex/alveolar apical) + /// Y/W compound final lookup or standard path + /// Initial + Prefix + ToneArrow + Suffix で構築 + /// /// internal static string ConvertSyllable(PinyinSyllable syllable, bool includeTones) { - var sb = new StringBuilder(16); + // ステップ 1: 声調矢印 + string toneArrow = (includeTones && syllable.Tone != Tone.Neutral) + ? s_toneArrows[(int)syllable.Tone] + : string.Empty; + + // ステップ 2: 特別ケース判定 - // 声母の Misaki IPA - if (syllable.Initial != Initial.None) + // a. Initial.None + Final.O → 単独感嘆詞 "ɔ" (NOT wo) + if (syllable.Initial == Initial.None && syllable.Final == Final.O) { - if (syllable.Initial == Initial.Y || syllable.Initial == Initial.W) - { - // y/w は韻母側が対応する母音で始まる場合、半母音を省略する - if (!ShouldOmitSemivowel(syllable.Initial, syllable.Final)) - { - sb.Append(s_initialMisaki[syllable.Initial]); - } - } - else - { - sb.Append(s_initialMisaki[syllable.Initial]); - } + return "\u0254" + toneArrow; // ɔ } - // 韻母の Misaki IPA - if (syllable.Final != Final.None) + // b. Final.Er (声母ありでも単独でも) → ɚ (U+025A) + if (syllable.Final == Final.Er) { - // zhi/chi/shi/ri の i はそり舌母音 ɻ̩ - if (syllable.Final == Final.I && IsRetroflex(syllable.Initial)) + var erSb = new StringBuilder(8); + if (syllable.Initial != Initial.None && s_initialMisaki.TryGetValue(syllable.Initial, out var erInitial)) { - sb.Append(s_retroflexApical); + erSb.Append(erInitial); } - // zi/ci/si の i は歯茎母音 ɹ̩ - else if (syllable.Final == Final.I && IsAlveolar(syllable.Initial)) + erSb.Append("\u025A"); // ɚ + erSb.Append(toneArrow); + return erSb.ToString(); + } + + // c. Zh/Ch/Sh/R + Final.I → 声母 + ɨ (retroflex apical) + if (syllable.Final == Final.I && IsRetroflex(syllable.Initial)) + { + return s_initialMisaki[syllable.Initial] + s_apicalMisaki + toneArrow; + } + + // d. Z/C/S + Final.I → 声母 + ɨ (alveolar apical) + if (syllable.Final == Final.I && IsAlveolar(syllable.Initial)) + { + return s_initialMisaki[syllable.Initial] + s_apicalMisaki + toneArrow; + } + + // ステップ 3: Y/W compound final lookup + string prefix; + string suffix; + bool omitInitial = false; + + if (s_yWCompoundMisaki.TryGetValue((syllable.Initial, syllable.Final), out var compound)) + { + prefix = compound.Prefix; + suffix = compound.Suffix; + omitInitial = compound.OmitInitial; + } + else + { + // Standard path: 韻母テンプレートから (Prefix, Suffix) を取得 + if (s_finalMisaki.TryGetValue(syllable.Final, out var finalTuple)) { - sb.Append(s_alveolarApical); + prefix = finalTuple.Prefix; + suffix = finalTuple.Suffix; } else { - sb.Append(s_finalMisaki[syllable.Final]); + prefix = string.Empty; + suffix = string.Empty; } } - // 声調矢印 - if (includeTones && syllable.Tone != Tone.Neutral) + // ステップ 4-5: 構築 + var result = new StringBuilder(16); + if (!omitInitial + && syllable.Initial != Initial.None + && s_initialMisaki.TryGetValue(syllable.Initial, out var initialIpa)) { - sb.Append(s_toneArrows[(int)syllable.Tone]); + result.Append(initialIpa); } - - return sb.ToString(); + result.Append(prefix); + result.Append(toneArrow); + result.Append(suffix); + return result.ToString(); } + // ──────────────────────────────────────────────────────────── + // ヘルパー + // ──────────────────────────────────────────────────────────── + /// zh/ch/sh/r のそり舌声母かどうか。 private static bool IsRetroflex(Initial initial) { @@ -223,43 +325,5 @@ private static bool IsAlveolar(Initial initial) || initial == Initial.C || initial == Initial.S; } - - /// y/w の半母音を省略すべきかどうかを判定する。 - private static bool ShouldOmitSemivowel(Initial initial, Final final_) - { - if (initial == Initial.Y) - { - // y + i系韻母: 韻母が i で始まるので j は不要 - // y + ü系韻母: 韻母が y[IPA] で始まるので j は不要 - switch (final_) - { - case Final.I: - case Final.In: - case Final.Ing: - case Final.V: - case Final.Ve: - case Final.Van: - case Final.Vn: - return true; - default: - return false; - } - } - - if (initial == Initial.W) - { - // w + u系韻母: 韻母が u で始まるので w は不要 - switch (final_) - { - case Final.U: - case Final.Un: - return true; - default: - return false; - } - } - - return false; - } } } diff --git a/tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiConvertTests.cs b/tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiConvertTests.cs index 355552f..791f8e9 100644 --- a/tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiConvertTests.cs +++ b/tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiConvertTests.cs @@ -1,3 +1,14 @@ +// Expected values verified against misaki 0.9.4 via uv run (Phase 1-R) +// Ground truth: .claude/tmp/misaki-gold.txt (137 entries, 実測値) +// Spec reference: .claude/tmp/misaki-spec.md +// +// 重要: 本ファイルの期待値はすべて gold.txt の実測値に基づく。 +// 旧実装の期待値 (ʈʂ, u̯ŋ, yan, etc.) は無効。 +// Misaki 合字: ʨ (U+02A8), ʦ (U+02A6), ꭧ (U+AB67) +// Misaki 母音: ɤ (U+0264), ə (U+0259), ʊ (U+028A), ɨ (U+0268), +// ɥ (U+0265), ɛ (U+025B), ɚ (U+025A), ɔ (U+0254) +// Misaki 声調矢印: → (U+2192), ↗ (U+2197), ↓ (U+2193), ↘ (U+2198) + using DotNetG2P.Chinese; using Xunit; @@ -5,225 +16,446 @@ namespace DotNetG2P.Tests.ChineseG2P { /// /// PinyinToMisaki.Convert / ConvertSyllable の単体テスト。 - /// 声母・韻母・声調・半母音省略・そり舌/歯茎母音など、 - /// PinyinToIpa と同一の変換ロジックが Misaki マッピングでも正しく機能することを検証する。 + /// 期待値はすべて uv misaki 0.9.4 実測値(.claude/tmp/misaki-gold.txt)。 + /// 旧実装との差異: + /// + /// zh/ch: ʈʂ → ꭧ (U+AB67 合字) + /// ong/iong: u̯ŋ → ʊŋ (U+028A) + /// ai/ei/ao/ou: 末尾 U+032F 除去 + /// retroflex/alveolar apical: ɻ̩/ɹ̩ → ɨ (U+0268) + /// Er: əɻ → ɚ (U+025A) + /// 声調: ˥˥ 等の IPA tone letter → 矢印 → ↗ ↓ ↘ + /// üe: jɛ → ɥe (半母音 ɥ) + /// üan: jan → ɥɛn + /// /// public class PinyinToMisakiConvertTests { - // ===== 声調マーカーのテスト(4声 + 軽声) ===== + // ════════════════════════════════════════════════════════════ + // 1. 4 声 + 軽声 × ma 系 + // gold.txt: ma1-ma5 + // ════════════════════════════════════════════════════════════ [Theory] - [InlineData("mā", "ma\u2192")] // 1声 → - [InlineData("má", "ma\u2197")] // 2声 ↗ - [InlineData("mǎ", "ma\u2193")] // 3声 ↓ - [InlineData("mà", "ma\u2198")] // 4声 ↘ - public void Convert_AllTones_ReturnsCorrectArrow(string pinyin, string expected) + [InlineData("mā", "ma\u2192")] // ma1 1声 → + [InlineData("má", "ma\u2197")] // ma2 2声 ↗ + [InlineData("mǎ", "ma\u2193")] // ma3 3声 ↓ + [InlineData("mà", "ma\u2198")] // ma4 4声 ↘ + [InlineData("ma", "ma")] // ma5 軽声 矢印なし + public void Convert_MaSeries_AllTones(string pinyin, string expected) { Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); } - [Theory] - [InlineData("ma", "ma")] // 声調記号なし = 軽声 → 矢印なし - [InlineData("de", "t\u0264")] // 助詞「的」の軽声読み → tɤ (矢印なし) - public void Convert_NeutralTone_OmitsArrow(string pinyin, string expected) - { - Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); - } + // ════════════════════════════════════════════════════════════ + // 2. 全声母 × a/i 系代表 (21 エントリ) + // gold.txt: 行 6-25 + // ════════════════════════════════════════════════════════════ [Theory] - [InlineData("ma1", "ma\u2192")] // 数字声調形式 1声 - [InlineData("ma2", "ma\u2197")] // 数字声調形式 2声 - [InlineData("ma3", "ma\u2193")] // 数字声調形式 3声 - [InlineData("ma4", "ma\u2198")] // 数字声調形式 4声 - public void Convert_NumericToneFormat_NormalizedCorrectly(string pinyin, string expected) + // 両唇・唇歯音 (b/p/m/f) + [InlineData("bā", "pa\u2192")] // ba1 → pa→ + [InlineData("pá", "p\u02B0a\u2197")] // pa2 → pʰa↗ + [InlineData("fǎ", "fa\u2193")] // fa3 → fa↓ + // 歯茎音 (d/t/n/l) + [InlineData("dà", "ta\u2198")] // da4 → ta↘ + [InlineData("tā", "t\u02B0a\u2192")] // ta1 → tʰa→ + [InlineData("ná", "na\u2197")] // na2 → na↗ + [InlineData("lǎ", "la\u2193")] // la3 → la↓ + // 軟口蓋音 (g/k/h) + [InlineData("gà", "ka\u2198")] // ga4 → ka↘ + [InlineData("kā", "k\u02B0a\u2192")] // ka1 → kʰa→ + [InlineData("há", "xa\u2197")] // ha2 → xa↗ + // 歯茎硬口蓋音 (j/q/x) — 合字 ʨ を使用 + [InlineData("jī", "\u02A8i\u2192")] // ji1 → ʨi→ + [InlineData("qí", "\u02A8\u02B0i\u2197")] // qi2 → ʨʰi↗ + [InlineData("xǐ", "\u0255i\u2193")] // xi3 → ɕi↓ + public void Convert_AllInitials_RepresentativeVowel(string pinyin, string expected) { Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); } - // ===== includeTones=false で声調を省略 ===== - - [Theory] - [InlineData("mā", "ma")] - [InlineData("nǐ", "ni")] - [InlineData("hǎo", "xau\u032F")] - [InlineData("wū", "u")] // w + u → u (半母音 w 省略) - public void Convert_IncludeTonesFalse_OmitsToneMarker(string pinyin, string expected) - { - Assert.Equal(expected, PinyinToMisaki.Convert(pinyin, includeTones: false)); - } - - // ===== Misaki 固有の声母差異 (j/q/z/c) ===== + // ════════════════════════════════════════════════════════════ + // 3. j/q/z/c/zh/ch 合字 (Misaki 固有、標準 IPA との差分) + // 標準 IPA では tɕ/tɕʰ/ts/tsʰ/ʈʂ/ʈʂʰ + // Misaki では ʨ/ʨʰ/ʦ/ʦʰ/ꭧ/ꭧʰ (合字 U+02A8, U+02A6, U+AB67) + // ════════════════════════════════════════════════════════════ [Fact] - public void Convert_J_UsesTcLigature() + public void Convert_J_UsesLigatureTc() { - // 標準IPA: "tɕi˥˥" → Misaki: "ʨi→" + // ji1: 標準 IPA tɕi˥˥ → Misaki ʨi→ Assert.Equal("\u02A8i\u2192", PinyinToMisaki.Convert("jī")); } [Fact] - public void Convert_Q_UsesTcLigatureWithAspiration() + public void Convert_Q_UsesLigatureTcWithAspiration() { - // 標準IPA: "tɕʰi˥˥" → Misaki: "ʨʰi→" + // qi1: 標準 IPA tɕʰi˥˥ → Misaki ʨʰi→ Assert.Equal("\u02A8\u02B0i\u2192", PinyinToMisaki.Convert("qī")); } [Fact] - public void Convert_Z_UsesTsLigatureBeforeVowel() + public void Convert_Z_UsesLigatureTsBeforeVowel() { - // 標準IPA: "tsa˥˥" → Misaki: "ʦa→" + // za1: Z + A → ʦ + a + → = ʦa→ Assert.Equal("\u02A6a\u2192", PinyinToMisaki.Convert("zā")); } [Fact] - public void Convert_C_UsesTsLigatureWithAspiration() + public void Convert_C_UsesLigatureTsWithAspiration() { - // 標準IPA: "tsʰa˥˥" → Misaki: "ʦʰa→" + // ca1: C + A → ʦʰ + a + → = ʦʰa→ Assert.Equal("\u02A6\u02B0a\u2192", PinyinToMisaki.Convert("cā")); } - // ===== Misaki 固有の韻母差異 (二重母音の非音節化符号) ===== + [Fact] + public void Convert_Zh_UsesMisakiLigatureNotRetroflex() + { + // zhi1: Zh + I (retroflex apical) → ꭧ + ɨ + → = ꭧɨ→ + // 旧実装 (ʈʂɻ̩) とは異なる + Assert.Equal("\uAB67\u0268\u2192", PinyinToMisaki.Convert("zhī")); + } + + [Fact] + public void Convert_Ch_UsesMisakiLigatureWithAspiration() + { + // chi1: Ch + I (retroflex apical) → ꭧʰ + ɨ + → = ꭧʰɨ→ + Assert.Equal("\uAB67\u02B0\u0268\u2192", PinyinToMisaki.Convert("chī")); + } + + // ════════════════════════════════════════════════════════════ + // 4. 二重母音 U+032F strip 検証 + // 旧実装は ai/ei/ao/ou に U+032F を付けていたが + // Misaki では strip 済みの ai/ei/au/ou を使う + // ════════════════════════════════════════════════════════════ [Theory] - [InlineData("bái", "pai\u032F\u2197")] // b+ai 2声 → pai̯↗ - [InlineData("mèi", "mei\u032F\u2198")] // m+ei 4声 → mei̯↘ - [InlineData("māo", "mau\u032F\u2192")] // m+ao 1声 → mau̯→ - [InlineData("dòu", "tou\u032F\u2198")] // d+ou 4声 → tou̯↘ - public void Convert_Diphthongs_UseNonSyllabicMark(string pinyin, string expected) + [InlineData("bái", "pai\u2197")] // B+Ai → pai↗ (NOT pai̯↗) + [InlineData("měi", "mei\u2193")] // M+Ei → mei↓ + [InlineData("mǎo", "mau\u2193")] // M+Ao → mau↓ (au, NOT ao) + [InlineData("dòu", "tou\u2198")] // D+Ou → tou↘ + [InlineData("miáo", "mjau\u2197")] // M+Iao → mjau↗ + [InlineData("liù", "ljou\u2198")] // L+Iu(iou) → ljou↘ + [InlineData("guāi", "kwai\u2192")] // G+Uai → kwai→ + [InlineData("duì", "twei\u2198")] // D+Ui(uei) → twei↘ + public void Convert_Diphthongs_NoNonSyllabicMark(string pinyin, string expected) { Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); + // 重要: 期待値に U+032F を含まない + Assert.DoesNotContain("\u032F", PinyinToMisaki.Convert(pinyin)); } + // ════════════════════════════════════════════════════════════ + // 5. CVC 声調位置検証 (声調が末尾ではなく coda の前) + // 例: man1 → ma→n (NOT man→) + // ════════════════════════════════════════════════════════════ + [Theory] - [InlineData("miáo", "miau\u032F\u2197")] // m+iao 2声 → miau̯↗ - [InlineData("liù", "liou\u032F\u2198")] // l+iu(iou) 4声 → liou̯↘ - [InlineData("guāi", "kuai\u032F\u2192")] // g+uai 1声 → kuai̯→ - [InlineData("duì", "tuei\u032F\u2198")] // d+ui(uei) 4声 → tuei̯↘ - public void Convert_ComplexDiphthongs_UseNonSyllabicMark(string pinyin, string expected) + [InlineData("mān", "ma\u2192n")] // man1 → ma→n + [InlineData("mán", "ma\u2197n")] // man2 → ma↗n + [InlineData("mǎn", "ma\u2193n")] // man3 → ma↓n + [InlineData("màn", "ma\u2198n")] // man4 → ma↘n + [InlineData("māng", "ma\u2192\u014B")] // mang1 → ma→ŋ + [InlineData("máng", "ma\u2197\u014B")] // mang2 → ma↗ŋ + [InlineData("mǎng", "ma\u2193\u014B")] // mang3 → ma↓ŋ + [InlineData("màng", "ma\u2198\u014B")] // mang4 → ma↘ŋ + [InlineData("mēn", "m\u0259\u2192n")] // men1 → mə→n + [InlineData("méng", "m\u0259\u2197\u014B")] // meng2 → mə↗ŋ + [InlineData("dōng", "t\u028A\u2192\u014B")] // dong1 → tʊ→ŋ + [InlineData("xióng", "\u0255j\u028A\u2197\u014B")] // xiong2 → ɕjʊ↗ŋ + public void Convert_CVC_ToneBetweenNucleusAndCoda(string pinyin, string expected) { Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); } - [Fact] - public void Convert_Ong_UsesNonSyllabicUBeforeNg() + // ════════════════════════════════════════════════════════════ + // 6. i 系韻母 j 半母音 (Ia/Ie/Iao/Iu/Ian/Iang/Iong は j 付き、 + // In/Ing は j なし) + // ════════════════════════════════════════════════════════════ + + [Theory] + [InlineData("jiā", "\u02A8ja\u2192")] // jia1 → ʨja→ + [InlineData("jiān", "\u02A8j\u025B\u2192n")] // jian1 → ʨjɛ→n + [InlineData("jiāng", "\u02A8ja\u2192\u014B")] // jiang1 → ʨja→ŋ + [InlineData("jiāo", "\u02A8jau\u2192")] // jiao1 → ʨjau→ + [InlineData("jiē", "\u02A8je\u2192")] // jie1 → ʨje→ + [InlineData("jiū", "\u02A8jou\u2192")] // jiu1 (iou) → ʨjou→ + [InlineData("jīn", "\u02A8i\u2192n")] // jin1 → ʨi→n (j なし) + [InlineData("jīng", "\u02A8i\u2192\u014B")] // jing1 → ʨi→ŋ (j なし) + [InlineData("jiōng", "\u02A8j\u028A\u2192\u014B")] // jiong1 → ʨjʊ→ŋ + [InlineData("liā", "lja\u2192")] // lia1 → lja→ + [InlineData("liē", "lje\u2192")] // lie1 → lje→ + [InlineData("liān", "lj\u025B\u2192n")] // lian1 → ljɛ→n + public void Convert_IFinals_jSemivowel(string pinyin, string expected) { - // t + ong + 1声 → t + u̯ŋ + → = "tu̯ŋ→" - Assert.Equal("tu\u032F\u014B\u2192", PinyinToMisaki.Convert("dōng")); + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); } - [Fact] - public void Convert_Iong_UsesNonSyllabicUBeforeNg() + // ════════════════════════════════════════════════════════════ + // 7. u 系韻母 w 半母音 + // ════════════════════════════════════════════════════════════ + + [Theory] + [InlineData("guā", "kwa\u2192")] // gua1 → kwa→ + [InlineData("guāi", "kwai\u2192")] // guai1 → kwai→ + [InlineData("guān", "kwa\u2192n")] // guan1 → kwa→n + [InlineData("guāng", "kwa\u2192\u014B")] // guang1 → kwa→ŋ + [InlineData("guì", "kwei\u2198")] // gui1(uei) → kwei↘ + [InlineData("gùn", "kw\u0259\u2198n")] // gun1(uen) → kwə↘n + [InlineData("guǒ", "kwo\u2193")] // guo3 → kwo↓ + [InlineData("duō", "two\u2192")] // duo1 → two→ + public void Convert_UFinals_wSemivowel(string pinyin, string expected) { - // x + iong + 2声 → ɕ + iu̯ŋ + ↗ = "ɕiu̯ŋ↗" - Assert.Equal("\u0255iu\u032F\u014B\u2197", PinyinToMisaki.Convert("xióng")); + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); } - // ===== そり舌母音 (zh/ch/sh/r + i) ===== + // ════════════════════════════════════════════════════════════ + // 8. ü 系韻母 ɥ (撮口呼) + // j/q/x + ü → ʨy (Vn/V は ɥ 省略)、ɥe (Ve)、ɥɛn (Van) + // ════════════════════════════════════════════════════════════ [Theory] - [InlineData("zhī", "\u0288\u0282\u027B\u0329\u2192")] // ʈʂɻ̩→ - [InlineData("chī", "\u0288\u0282\u02B0\u027B\u0329\u2192")] // ʈʂʰɻ̩→ - [InlineData("shī", "\u0282\u027B\u0329\u2192")] // ʂɻ̩→ - [InlineData("rì", "\u027B\u027B\u0329\u2198")] // ɻɻ̩↘ - public void Convert_RetroflexPlusI_UsesRetroflexApical(string pinyin, string expected) + [InlineData("juē", "\u02A8\u0265e\u2192")] // jue1 → ʨɥe→ + [InlineData("juān", "\u02A8\u0265\u025B\u2192n")] // juan1 → ʨɥɛ→n + [InlineData("jūn", "\u02A8y\u2192n")] // jun1 (ü+n → y+n、ɥ 省略) → ʨy→n + [InlineData("lǜ", "ly\u2198")] // lv4 → ly↘ + [InlineData("lüè", "l\u0265e\u2198")] // lve4 → lɥe↘ + [InlineData("nǚ", "ny\u2193")] // nv3 → ny↓ + public void Convert_VFinals_yOrEta(string pinyin, string expected) { Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); } - // ===== 歯茎母音 (z/c/s + i) ===== + // ════════════════════════════════════════════════════════════ + // 9. Y + Final (Y は半母音 j に展開、一部は Initial 省略) + // gold.txt: yi=i, yin=in, ying=iŋ, yu=y, yun=yn (omit=true) + // ya=ja, ye=je, yao=jau, you=jou, yan=jɛn, yang=jaŋ, + // yong=jʊŋ, yue=ɥe, yuan=ɥɛn (omit=false) + // ════════════════════════════════════════════════════════════ [Theory] - [InlineData("zī", "\u02A6\u0279\u0329\u2192")] // ʦɹ̩→ (Misaki 合字 ʦ) - [InlineData("cī", "\u02A6\u02B0\u0279\u0329\u2192")] // ʦʰɹ̩→ - [InlineData("sī", "s\u0279\u0329\u2192")] // sɹ̩→ - public void Convert_AlveolarPlusI_UsesAlveolarApical(string pinyin, string expected) + // Omit=true (j/ɥ 省略) + [InlineData("yī", "i\u2192")] // yi1 → i→ + [InlineData("yīn", "i\u2192n")] // yin1 → i→n + [InlineData("yīng", "i\u2192\u014B")] // ying1 → i→ŋ + [InlineData("yū", "y\u2192")] // yu1 → y→ (ü 入力) + [InlineData("yūn", "y\u2192n")] // yun1 → y→n + // Omit=false + [InlineData("yā", "ja\u2192")] // ya1 → ja→ + [InlineData("yē", "je\u2192")] // ye1 → je→ + [InlineData("yāo", "jau\u2192")] // yao1 → jau→ + [InlineData("yōu", "jou\u2192")] // you1 → jou→ + [InlineData("yān", "j\u025B\u2192n")] // yan1 → jɛ→n + [InlineData("yāng", "ja\u2192\u014B")] // yang1 → ja→ŋ + [InlineData("yōng", "j\u028A\u2192\u014B")] // yong1 → jʊ→ŋ + [InlineData("yuē", "\u0265e\u2192")] // yue1 → ɥe→ + [InlineData("yuān", "\u0265\u025B\u2192n")] // yuan1 → ɥɛ→n + public void Convert_YFinals_Semivowel(string pinyin, string expected) { Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); } - // ===== 半母音省略 (y/w + 対応韻母) ===== + // ════════════════════════════════════════════════════════════ + // 10. W + Final (W は半母音 w に展開、wu は省略) + // ════════════════════════════════════════════════════════════ [Theory] - [InlineData("yī", "i\u2192")] // y + i → i (半母音省略) - [InlineData("yīn", "in\u2192")] // y + in → in - [InlineData("yīng", "i\u014B\u2192")] // y + ing → iŋ - public void Convert_YPlusIFinals_OmitsSemivowel(string pinyin, string expected) + // Omit=true (w 省略) + [InlineData("wū", "u\u2192")] // wu1 → u→ + // Omit=false + [InlineData("wā", "wa\u2192")] // wa1 → wa→ + [InlineData("wāi", "wai\u2192")] // wai1 → wai→ + [InlineData("wān", "wa\u2192n")] // wan1 → wa→n + [InlineData("wāng", "wa\u2192\u014B")] // wang1 → wa→ŋ + [InlineData("wēi", "wei\u2192")] // wei1 → wei→ + [InlineData("wēn", "w\u0259\u2192n")] // wen1 → wə→n + [InlineData("wēng", "w\u0259\u2192\u014B")] // weng1 → wə→ŋ + [InlineData("wǒ", "wo\u2193")] // wo3 → wo↓ + public void Convert_WFinals_Semivowel(string pinyin, string expected) { Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); } + // ════════════════════════════════════════════════════════════ + // 11. そり舌 (Zh/Ch/Sh/R + I → 声母 + ɨ) + // gold.txt: 行 19-22, 63-66 + // ════════════════════════════════════════════════════════════ + [Theory] - [InlineData("yū", "y\u2192")] // y + ü → y (撮口呼は PinyinParser が Final.V として解釈) - [InlineData("yuē", "y\u025B\u2192")] // y + üe → yɛ - [InlineData("yuán", "yan\u2197")] // y + üan → yan - [InlineData("yūn", "yn\u2192")] // y + ün → yn - public void Convert_YPlusUFinals_OmitsSemivowel(string pinyin, string expected) + [InlineData("zhī", "\uAB67\u0268\u2192")] // ꭧɨ→ + [InlineData("chī", "\uAB67\u02B0\u0268\u2192")] // ꭧʰɨ→ + [InlineData("shī", "\u0282\u0268\u2192")] // ʂɨ→ + [InlineData("rì", "\u027B\u0268\u2198")] // ɻɨ↘ + public void Convert_RetroflexPlusI_UsesIBarred(string pinyin, string expected) { Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); } + // ════════════════════════════════════════════════════════════ + // 12. 歯茎 (Z/C/S + I → 声母 + ɨ) + // gold.txt: 行 23-25, 60-62 + // ════════════════════════════════════════════════════════════ + [Theory] - [InlineData("wū", "u\u2192")] // w + u → u (半母音 w 省略) - [InlineData("wù", "u\u2198")] // w + u 4声 → u↘ - public void Convert_WPlusUFinals_OmitsSemivowel(string pinyin, string expected) + [InlineData("zī", "\u02A6\u0268\u2192")] // ʦɨ→ + [InlineData("cī", "\u02A6\u02B0\u0268\u2192")] // ʦʰɨ→ + [InlineData("sī", "s\u0268\u2192")] // sɨ→ + public void Convert_AlveolarPlusI_UsesIBarred(string pinyin, string expected) { - // PinyinParser は "wen" を Initial.W + Final.En としてパースするため、 - // 半母音省略は "wu" 系列のみが対象となる。 Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); } + // ════════════════════════════════════════════════════════════ + // 13. 感嘆詞 Er (Final.Er → ɚ 単独、声母なしケース) + // gold.txt: 行 122-125 + // ════════════════════════════════════════════════════════════ + [Theory] - [InlineData("yā", "ja\u2192")] // y + a → ja (省略しない) - [InlineData("wǒ", "wo\u2193")] // w + o → wo (PinyinParser: W + O) - [InlineData("wài", "wai\u032F\u2198")] // w + ai → wai̯ (PinyinParser: W + Ai) - [InlineData("wēn", "w\u0259n\u2192")] // w + en → wən (PinyinParser: W + En) - public void Convert_YWPlusOtherFinals_KeepsSemivowel(string pinyin, string expected) + [InlineData("ēr", "\u025A\u2192")] // er1 → ɚ→ + [InlineData("ér", "\u025A\u2197")] // er2 → ɚ↗ + [InlineData("ěr", "\u025A\u2193")] // er3 → ɚ↓ + [InlineData("èr", "\u025A\u2198")] // er4 → ɚ↘ + public void Convert_Er_StandaloneIpaFormat(string pinyin, string expected) { Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); } - // ===== Issue #56 由来の参照例 ===== + // ════════════════════════════════════════════════════════════ + // 14. 感嘆詞 O (Initial.None + Final.O → ɔ) + // gold.txt: 行 126-129 + // 注意: 単独 "o" は ɔ だが、bo/po/mo/fo は pwo/pʰwo/mwo/fwo + // ════════════════════════════════════════════════════════════ - [Fact] - public void Convert_NiHao_ReturnsMisakiCompatibleOutput() + [Theory] + [InlineData("ō", "\u0254\u2192")] // o1 → ɔ→ + [InlineData("ó", "\u0254\u2197")] // o2 → ɔ↗ + [InlineData("ǒ", "\u0254\u2193")] // o3 → ɔ↓ + [InlineData("ò", "\u0254\u2198")] // o4 → ɔ↘ + public void Convert_Standalone_O_UsesOpenO(string pinyin, string expected) { - // PinyinToMisaki は個別音節のみ変換する(声調変調なし) - // nǐ (3声) → ni↓ - // hǎo (3声) → xau̯↓ - Assert.Equal("ni\u2193", PinyinToMisaki.Convert("nǐ")); - Assert.Equal("xau\u032F\u2193", PinyinToMisaki.Convert("hǎo")); + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); + } + + // ════════════════════════════════════════════════════════════ + // 15. bpmf + o → pwo/pʰwo/mwo/fwo (Final.O template は "wo") + // gold.txt: 行 130-133 + // ════════════════════════════════════════════════════════════ + + [Theory] + [InlineData("bō", "pwo\u2192")] // bo1 → pwo→ + [InlineData("pó", "p\u02B0wo\u2197")] // po2 → pʰwo↗ + [InlineData("mǒ", "mwo\u2193")] // mo3 → mwo↓ + [InlineData("fò", "fwo\u2198")] // fo4 → fwo↘ + public void Convert_BpmfPlusO_UsesWoTemplate(string pinyin, string expected) + { + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); + } + + // ════════════════════════════════════════════════════════════ + // 16. 軽声 + 代表韻母 (矢印なし) + // ════════════════════════════════════════════════════════════ + + [Theory] + [InlineData("ma", "ma")] // 軽声 ma + [InlineData("de", "t\u0264")] // 助詞「的」 → tɤ + [InlineData("le", "l\u0264")] // 助詞「了」 → lɤ + [InlineData("ba", "pa")] // 軽声 ba → pa + [InlineData("na", "na")] // 軽声 na → na + public void Convert_NeutralTone_NoArrow(string pinyin, string expected) + { + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); + } + + // ════════════════════════════════════════════════════════════ + // 17. includeTones=false (声調矢印を除去) + // ════════════════════════════════════════════════════════════ + + [Theory] + [InlineData("mā", "ma")] // ma→ → ma + [InlineData("nǐ", "ni")] // ni↓ → ni + [InlineData("hǎo", "xau")] // xau↓ → xau + [InlineData("zhōng", "\uAB67\u028A\u014B")] // ꭧʊ→ŋ → ꭧʊŋ + [InlineData("xióng", "\u0255j\u028A\u014B")] // ɕjʊ↗ŋ → ɕjʊŋ + [InlineData("ér", "\u025A")] // ɚ↗ → ɚ + public void Convert_IncludeTonesFalse_OmitsArrow(string pinyin, string expected) + { + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin, includeTones: false)); } - // ===== エッジケース ===== + // ════════════════════════════════════════════════════════════ + // 18. エッジケース + // ════════════════════════════════════════════════════════════ [Theory] [InlineData("")] [InlineData(null)] - public void Convert_NullOrEmpty_ReturnsEmpty(string pinyin) + public void Convert_NullOrEmpty_ReturnsEmptyString(string? pinyin) { - Assert.Equal(string.Empty, PinyinToMisaki.Convert(pinyin)); + Assert.Equal(string.Empty, PinyinToMisaki.Convert(pinyin!)); } [Theory] - [InlineData("xyz")] // 不正ピンイン - [InlineData("123")] // 数字のみ - [InlineData("!!!")] // 記号のみ - public void Convert_InvalidPinyin_ReturnsEmpty(string pinyin) + [InlineData("xyz")] // 不正声母/韻母の組 + [InlineData("123")] // 数字のみ + [InlineData("!!!")] // 記号のみ + public void Convert_InvalidInput_ReturnsEmptyString(string pinyin) { Assert.Equal(string.Empty, PinyinToMisaki.Convert(pinyin)); } - // ===== ConvertSyllable 直接テスト ===== + // ════════════════════════════════════════════════════════════ + // 19. Issue #56 参照 — 個別音節の変換 + // PinyinToMisaki は声調変調を行わず、各音節を個別に変換する + // ════════════════════════════════════════════════════════════ + + [Fact] + public void Convert_Ni_ReferenceExample() + { + // nǐ (3声) → ni↓ + Assert.Equal("ni\u2193", PinyinToMisaki.Convert("nǐ")); + } + + [Fact] + public void Convert_Hao_ReferenceExample() + { + // hǎo (3声) → xau↓ (U+032F なし) + Assert.Equal("xau\u2193", PinyinToMisaki.Convert("hǎo")); + } + + // ════════════════════════════════════════════════════════════ + // 20. 数字声調形式 (ma1, zhong1 等) + // 注意: lv1/jv1 等の v-only 形式は ToToneMarked の placement 制約により + // Convert(string) 経由では動作しないため ConvertSyllable で直接テスト + // ════════════════════════════════════════════════════════════ + + [Theory] + [InlineData("ma1", "ma\u2192")] // ma1 → ma→ + [InlineData("ma2", "ma\u2197")] // ma2 → ma↗ + [InlineData("ma3", "ma\u2193")] // ma3 → ma↓ + [InlineData("ma4", "ma\u2198")] // ma4 → ma↘ + [InlineData("zhong1", "\uAB67\u028A\u2192\u014B")] // zhong1 → ꭧʊ→ŋ + [InlineData("xiong2", "\u0255j\u028A\u2197\u014B")] // xiong2 → ɕjʊ↗ŋ + [InlineData("bo1", "pwo\u2192")] // bo1 → pwo→ + [InlineData("er1", "\u025A\u2192")] // er1 → ɚ→ + public void Convert_NumericToneForm_WorksEnd2End(string pinyin, string expected) + { + Assert.Equal(expected, PinyinToMisaki.Convert(pinyin)); + } + + // ════════════════════════════════════════════════════════════ + // 21. ConvertSyllable 直接 (internal API) + // ════════════════════════════════════════════════════════════ [Fact] - public void ConvertSyllable_BasicCase_ReturnsExpected() + public void ConvertSyllable_Basic_MAFirst() { var syllable = new PinyinSyllable(Initial.M, Final.A, Tone.First); Assert.Equal("ma\u2192", PinyinToMisaki.ConvertSyllable(syllable, includeTones: true)); } [Fact] - public void ConvertSyllable_WithoutTone_OmitsToneArrow() + public void ConvertSyllable_WithoutTones_OmitsArrow() { var syllable = new PinyinSyllable(Initial.N, Final.I, Tone.Third); Assert.Equal("ni", PinyinToMisaki.ConvertSyllable(syllable, includeTones: false)); @@ -238,35 +470,69 @@ public void ConvertSyllable_NeutralTone_NoArrowRegardlessOfFlag() } [Fact] - public void ConvertSyllable_ZeroInitial_OnlyFinal() + public void ConvertSyllable_ZeroInitial_AOnly() { - // ゼロ声母 (a, e, o など): 韻母のみ var syllable = new PinyinSyllable(Initial.None, Final.A, Tone.First); Assert.Equal("a\u2192", PinyinToMisaki.ConvertSyllable(syllable, includeTones: true)); } - // ===== PinyinToIpa との差分が期待通りに現れることの確認 ===== + [Fact] + public void ConvertSyllable_ZeroInitial_OBecomesOpenO() + { + // Initial.None + Final.O → ɔ (単独感嘆詞) + var syllable = new PinyinSyllable(Initial.None, Final.O, Tone.First); + Assert.Equal("\u0254\u2192", PinyinToMisaki.ConvertSyllable(syllable, includeTones: true)); + } + + [Fact] + public void ConvertSyllable_LvFirst_ViaSyllableApi() + { + // lv1 (L + V, tone 1) → ly→ (gold row 54) + // 注意: Convert(string) 経由では ToToneMarked が placement 不能のため動作しない + var syllable = new PinyinSyllable(Initial.L, Final.V, Tone.First); + Assert.Equal("ly\u2192", PinyinToMisaki.ConvertSyllable(syllable, includeTones: true)); + } + + [Fact] + public void ConvertSyllable_JvFirst_ViaSyllableApi() + { + // jv1 (J + V, tone 1) → ʨy→ (gold row 56) + var syllable = new PinyinSyllable(Initial.J, Final.V, Tone.First); + Assert.Equal("\u02A8y\u2192", PinyinToMisaki.ConvertSyllable(syllable, includeTones: true)); + } + + [Fact] + public void ConvertSyllable_JvnFirst_ViaSyllableApi() + { + // jvn1 (J + Vn, tone 1) → ʨy→n (gold row 59) + var syllable = new PinyinSyllable(Initial.J, Final.Vn, Tone.First); + Assert.Equal("\u02A8y\u2192n", PinyinToMisaki.ConvertSyllable(syllable, includeTones: true)); + } + + // ════════════════════════════════════════════════════════════ + // 22. PinyinToIpa との差分が期待通りに現れることの確認 + // (標準 IPA とは合字・U+032F・声調記号で差分が出る) + // ════════════════════════════════════════════════════════════ [Fact] public void Convert_DiffersFromStandardIpa_AtJInitial() { string misaki = PinyinToMisaki.Convert("jī", includeTones: false); string standardIpa = PinyinToIpa.Convert("jī", includeTones: false); - // Misaki: "\u02A8i" (ʨi), 標準IPA: "t\u0255i" (tɕi) Assert.NotEqual(standardIpa, misaki); - Assert.Equal("\u02A8i", misaki); - Assert.Equal("t\u0255i", standardIpa); + Assert.Equal("\u02A8i", misaki); // Misaki: ʨi + Assert.Equal("t\u0255i", standardIpa); // 標準: tɕi } [Fact] - public void Convert_DiffersFromStandardIpa_AtAiFinal() + public void Convert_DiffersFromStandardIpa_AtZhInitial() { - string misaki = PinyinToMisaki.Convert("bái", includeTones: false); - string standardIpa = PinyinToIpa.Convert("bái", includeTones: false); - // Misaki: "pai̯" (pai + U+032F), 標準IPA: "paɪ" (pa + U+026A) + string misaki = PinyinToMisaki.Convert("zhī", includeTones: false); + string standardIpa = PinyinToIpa.Convert("zhī", includeTones: false); Assert.NotEqual(standardIpa, misaki); - Assert.Equal("pai\u032F", misaki); - Assert.Equal("pa\u026A", standardIpa); + Assert.Equal("\uAB67\u0268", misaki); // Misaki: ꭧɨ + // 標準 IPA は異なる表記 (ʈʂɻ̩ 等) を使う + Assert.NotEqual(misaki, standardIpa); } [Fact] @@ -274,10 +540,18 @@ public void Convert_DiffersFromStandardIpa_AtToneMarker() { string misaki = PinyinToMisaki.Convert("mā", includeTones: true); string standardIpa = PinyinToIpa.Convert("mā", includeTones: true); - // Misaki: "ma→" (U+2192), 標準IPA: "ma˥˥" (U+02E5 U+02E5) Assert.NotEqual(standardIpa, misaki); - Assert.Equal("ma\u2192", misaki); - Assert.Equal("ma\u02E5\u02E5", standardIpa); + Assert.Equal("ma\u2192", misaki); // Misaki: ma→ + Assert.Equal("ma\u02E5\u02E5", standardIpa); // 標準: ma˥˥ + } + + [Fact] + public void Convert_DiffersFromStandardIpa_AtOngFinal() + { + // Misaki は Ong に ʊ (U+028A) を使う、標準 IPA は u̯ 等 + string misaki = PinyinToMisaki.Convert("dōng", includeTones: false); + Assert.Equal("t\u028A\u014B", misaki); // Misaki: tʊŋ + Assert.DoesNotContain("\u032F", misaki); // U+032F は含まれない } } } diff --git a/tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiMappingTests.cs b/tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiMappingTests.cs index b849a29..a3aabec 100644 --- a/tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiMappingTests.cs +++ b/tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiMappingTests.cs @@ -5,106 +5,150 @@ namespace DotNetG2P.Tests.ChineseG2P { /// /// PinyinToMisaki のマッピングテーブル単体テスト。 - /// 声母 22 エントリ + 韻母 36 エントリ + 声調 5 エントリの全マッピングを検証する。 - /// Convert メソッドのロジックテストは T02 の PinyinToMisakiConvertTests 側で行う。 + /// 声母 21 エントリ + 韻母 36 エントリ(Prefix+Suffix 分離) + 声調 5 エントリ + Y/W 複合韻母 23 エントリの + /// 全マッピングを検証する。 + /// + /// Misaki 仕様 (`.claude/tmp/misaki-spec.md`) は uv misaki 0.9.4 の実測値で検証済み: + /// - j/q → ligature ʨ (U+02A8) / ʨʰ + /// - zh/ch → ligature ꭧ (U+AB67) / ꭧʰ (NOT ʈʂ) + /// - z/c → ligature ʦ (U+02A6) / ʦʰ + /// - apical vowel は zh/ch/sh/r/z/c/s + i で共通 "ɨ" (U+0268) + /// - ɥ (U+0265) は ü 系韻母用半母音 (NOT y) + /// - 二重母音の非音節化符号 U+032F は**事前除去済み**テンプレートを使う + /// - 声調は中間位置 (prefix + tone + suffix 方式) + /// - Y/W は s_initialMisaki には存在せず、(Initial, Final) 複合テーブル側で解決 + /// + /// Convert メソッドのロジックテストは PinyinToMisakiConvertTests 側で行う。 /// public class PinyinToMisakiMappingTests { - // ===== 声母マッピング ===== + // ============================================================ + // 声母マッピング (21 エントリ、Y/W は compound final 層で処理) + // ============================================================ + + // ── 両唇音・唇歯音 (4) ── [Theory] - [InlineData(Initial.B, "p")] + [InlineData(Initial.B, "p")] // p U+0070 [InlineData(Initial.P, "p\u02B0")] // pʰ - [InlineData(Initial.M, "m")] - [InlineData(Initial.F, "f")] + [InlineData(Initial.M, "m")] // m U+006D + [InlineData(Initial.F, "f")] // f U+0066 public void InitialMapping_Labials_ReturnsExpectedMisakiIpa(Initial initial, string expected) { Assert.Equal(expected, PinyinToMisaki.GetInitialMisaki(initial)); } + // ── 歯茎音 (4) ── + [Theory] - [InlineData(Initial.D, "t")] + [InlineData(Initial.D, "t")] // t U+0074 [InlineData(Initial.T, "t\u02B0")] // tʰ - [InlineData(Initial.N, "n")] - [InlineData(Initial.L, "l")] + [InlineData(Initial.N, "n")] // n U+006E + [InlineData(Initial.L, "l")] // l U+006C public void InitialMapping_Alveolars_ReturnsExpectedMisakiIpa(Initial initial, string expected) { Assert.Equal(expected, PinyinToMisaki.GetInitialMisaki(initial)); } + // ── 軟口蓋音 (3) ── + [Theory] - [InlineData(Initial.G, "k")] + [InlineData(Initial.G, "k")] // k U+006B [InlineData(Initial.K, "k\u02B0")] // kʰ - [InlineData(Initial.H, "x")] + [InlineData(Initial.H, "x")] // x U+0078 public void InitialMapping_Velars_ReturnsExpectedMisakiIpa(Initial initial, string expected) { Assert.Equal(expected, PinyinToMisaki.GetInitialMisaki(initial)); } - // ── Misaki 差異: j/q は合字 ʨ/ʨʰ (U+02A8) を使用 ── + // ── 歯茎硬口蓋音 (3) ── Misaki 差異: j/q は合字 ʨ (U+02A8) を使用 [Fact] public void InitialMapping_J_UsesTcLigature() { - // DotNetG2P 標準IPA: "t\u0255" (tɕ) - // Misaki: "\u02A8" (ʨ、合字) + // 標準IPA の tɕ (U+0074 U+0255) ではなく、Misaki は ligature ʨ (U+02A8) を使う。 + // uv misaki 0.9.4 の実測で confirmed (gh api + uv run で検証済)。 Assert.Equal("\u02A8", PinyinToMisaki.GetInitialMisaki(Initial.J)); } [Fact] public void InitialMapping_Q_UsesTcLigatureWithAspiration() { - // DotNetG2P 標準IPA: "t\u0255\u02B0" (tɕʰ) - // Misaki: "\u02A8\u02B0" (ʨʰ) + // Misaki: ʨʰ (U+02A8 U+02B0) Assert.Equal("\u02A8\u02B0", PinyinToMisaki.GetInitialMisaki(Initial.Q)); } [Fact] - public void InitialMapping_X_UnchangedFromStandardIpa() + public void InitialMapping_X_ReturnsPalatalFricative() { + // x → ɕ (U+0255、歯茎硬口蓋摩擦音)。標準IPAと同一。 Assert.Equal("\u0255", PinyinToMisaki.GetInitialMisaki(Initial.X)); } + // ── そり舌音 (4) ── Misaki 差異: zh/ch は合字 ꭧ (U+AB67) を使用 + + [Fact] + public void InitialMapping_Zh_UsesTsRetroflexLigature() + { + // 標準IPA の ʈʂ (U+0288 U+0282) ではなく、Misaki は ligature ꭧ (U+AB67) を使う。 + // uv misaki 0.9.4 の実測で confirmed。Kokoro 82M vocab にも含まれる。 + Assert.Equal("\uAB67", PinyinToMisaki.GetInitialMisaki(Initial.Zh)); + } + + [Fact] + public void InitialMapping_Ch_UsesTsRetroflexLigatureWithAspiration() + { + // Misaki: ꭧʰ (U+AB67 U+02B0) + Assert.Equal("\uAB67\u02B0", PinyinToMisaki.GetInitialMisaki(Initial.Ch)); + } + [Theory] - [InlineData(Initial.Zh, "\u0288\u0282")] // ʈʂ - [InlineData(Initial.Ch, "\u0288\u0282\u02B0")] // ʈʂʰ - [InlineData(Initial.Sh, "\u0282")] // ʂ - [InlineData(Initial.R, "\u027B")] // ɻ - public void InitialMapping_Retroflex_ReturnsExpectedMisakiIpa(Initial initial, string expected) + [InlineData(Initial.Sh, "\u0282")] // ʂ 標準IPAと同一 + [InlineData(Initial.R, "\u027B")] // ɻ 標準IPAと同一 + public void InitialMapping_RetroflexFricatives_ReturnsExpectedMisakiIpa(Initial initial, string expected) { Assert.Equal(expected, PinyinToMisaki.GetInitialMisaki(initial)); } - // ── Misaki 差異: z/c は合字 ʦ/ʦʰ (U+02A6) を使用 ── + // ── 歯茎破擦音・摩擦音 (3) ── Misaki 差異: z/c は合字 ʦ (U+02A6) を使用 [Fact] public void InitialMapping_Z_UsesTsLigature() { - // DotNetG2P 標準IPA: "ts" - // Misaki: "\u02A6" (ʦ、合字) + // 標準IPA の ts (U+0074 U+0073) ではなく、Misaki は ligature ʦ (U+02A6) を使う。 Assert.Equal("\u02A6", PinyinToMisaki.GetInitialMisaki(Initial.Z)); } [Fact] public void InitialMapping_C_UsesTsLigatureWithAspiration() { - // DotNetG2P 標準IPA: "ts\u02B0" (tsʰ) - // Misaki: "\u02A6\u02B0" (ʦʰ) + // Misaki: ʦʰ (U+02A6 U+02B0) Assert.Equal("\u02A6\u02B0", PinyinToMisaki.GetInitialMisaki(Initial.C)); } [Fact] - public void InitialMapping_S_UnchangedFromStandardIpa() + public void InitialMapping_S_ReturnsAlveolarFricative() { + // s → s (U+0073)。標準IPAと同一。 Assert.Equal("s", PinyinToMisaki.GetInitialMisaki(Initial.S)); } - [Theory] - [InlineData(Initial.Y, "j")] - [InlineData(Initial.W, "w")] - public void InitialMapping_Semivowels_ReturnsExpectedMisakiIpa(Initial initial, string expected) + // ── Y/W と None は s_initialMisaki に含まれない ── + + [Fact] + public void InitialMapping_Y_NotInInitialTable_ReturnsEmpty() { - Assert.Equal(expected, PinyinToMisaki.GetInitialMisaki(initial)); + // Y は compound final テーブル (TryGetYWCompound) で処理される。 + // GetInitialMisaki では空文字を返すこと。 + Assert.Equal(string.Empty, PinyinToMisaki.GetInitialMisaki(Initial.Y)); + } + + [Fact] + public void InitialMapping_W_NotInInitialTable_ReturnsEmpty() + { + // W は compound final テーブル (TryGetYWCompound) で処理される。 + // GetInitialMisaki では空文字を返すこと。 + Assert.Equal(string.Empty, PinyinToMisaki.GetInitialMisaki(Initial.W)); } [Fact] @@ -113,165 +157,501 @@ public void InitialMapping_None_ReturnsEmpty() Assert.Equal(string.Empty, PinyinToMisaki.GetInitialMisaki(Initial.None)); } - // ===== 韻母マッピング ===== + // ============================================================ + // 韻母 Prefix/Suffix マッピング (36 エントリ) + // Misaki の韻母は (prefix, suffix) に分離し、間に声調矢印を挟む方式。 + // 例: Final.An → ("a", "n") + tone → "a→n" + // ============================================================ + + // ── 開口呼 単韻母 (3) ── + + [Theory] + [InlineData(Final.A, "a", "")] // a + [InlineData(Final.O, "wo", "")] // wo (bpmf + o は pwo/pʰwo 形式) + [InlineData(Final.E, "\u0264", "")] // ɤ U+0264 + public void FinalMapping_OpenSimpleVowels_ReturnsExpectedPrefixSuffix( + Final final_, string expectedPrefix, string expectedSuffix) + { + Assert.Equal((expectedPrefix, expectedSuffix), PinyinToMisaki.GetFinalMisaki(final_)); + } + + // ── 開口呼 複韻母 (4) ── Misaki: U+032F **なし** (事前除去済み) + + [Theory] + [InlineData(Final.Ai, "ai", "")] // ai (NO U+032F) + [InlineData(Final.Ei, "ei", "")] // ei + [InlineData(Final.Ao, "au", "")] // au + [InlineData(Final.Ou, "ou", "")] // ou + public void FinalMapping_OpenDiphthongs_NoNonSyllabicMark( + Final final_, string expectedPrefix, string expectedSuffix) + { + Assert.Equal((expectedPrefix, expectedSuffix), PinyinToMisaki.GetFinalMisaki(final_)); + } + + [Fact] + public void FinalMapping_Ai_ContainsNoCombiningInvertedBreveBelow() + { + // U+032F (COMBINING INVERTED BREVE BELOW) は事前除去済み。 + // Kokoro vocab に含まれないため、テンプレに残してはならない。 + var result = PinyinToMisaki.GetFinalMisaki(Final.Ai); + Assert.DoesNotContain("\u032F", result.Prefix); + Assert.DoesNotContain("\u032F", result.Suffix); + } + + // ── 開口呼 鼻韻母 (5) ── Prefix + Suffix 分離、間に声調を挟む [Theory] - [InlineData(Final.A, "a")] - [InlineData(Final.O, "o")] - [InlineData(Final.E, "\u0264")] // ɤ - public void FinalMapping_SimpleVowels_ReturnsExpectedMisakiIpa(Final final_, string expected) + [InlineData(Final.An, "a", "n")] // a→n + [InlineData(Final.En, "\u0259", "n")] // ə→n + [InlineData(Final.Ang, "a", "\u014B")] // a→ŋ + [InlineData(Final.Eng, "\u0259", "\u014B")] // ə→ŋ + public void FinalMapping_OpenNasals_ReturnsExpectedPrefixSuffix( + Final final_, string expectedPrefix, string expectedSuffix) { - Assert.Equal(expected, PinyinToMisaki.GetFinalMisaki(final_)); + Assert.Equal((expectedPrefix, expectedSuffix), PinyinToMisaki.GetFinalMisaki(final_)); } - // ── Misaki 差異: 二重母音に非音節化符号 U+032F を付与 ── + [Fact] + public void FinalMapping_Ong_UsesUpsilonBeforeNg() + { + // Misaki: ʊ (U+028A) + ŋ (U+014B)。標準IPA と同一だが U+032F は使わない。 + // 旧 "u̯ŋ" (U+0075 U+032F U+014B) は仕様誤認、正しくは "ʊŋ"。 + Assert.Equal(("\u028A", "\u014B"), PinyinToMisaki.GetFinalMisaki(Final.Ong)); + } + + // ── 齊齒呼 単韻母 (1) ── [Theory] - [InlineData(Final.Ai, "ai\u032F")] // ai̯ (標準IPA: aɪ) - [InlineData(Final.Ei, "ei\u032F")] // ei̯ (標準IPA: eɪ) - [InlineData(Final.Ao, "au\u032F")] // au̯ (標準IPA: aʊ) - [InlineData(Final.Ou, "ou\u032F")] // ou̯ (標準IPA: oʊ) - public void FinalMapping_OpenDiphthongs_UseNonSyllabicMark(Final final_, string expected) + [InlineData(Final.I, "i", "")] // i + public void FinalMapping_I_ReturnsExpectedPrefixSuffix( + Final final_, string expectedPrefix, string expectedSuffix) { - Assert.Equal(expected, PinyinToMisaki.GetFinalMisaki(final_)); + Assert.Equal((expectedPrefix, expectedSuffix), PinyinToMisaki.GetFinalMisaki(final_)); } + // ── 齊齒呼 複韻母 (5) ── + [Theory] - [InlineData(Final.Iao, "iau\u032F")] // iau̯ - [InlineData(Final.Iu, "iou\u032F")] // iou̯ (iu = iou) - [InlineData(Final.Uai, "uai\u032F")] // uai̯ - [InlineData(Final.Ui, "uei\u032F")] // uei̯ (ui = uei) - public void FinalMapping_ComplexDiphthongs_UseNonSyllabicMark(Final final_, string expected) + [InlineData(Final.Ia, "ja", "")] // ja (j 半母音) + [InlineData(Final.Iao, "jau", "")] // jau (NO U+032F) + [InlineData(Final.Iu, "jou", "")] // jou (Misaki "iou"、NO U+032F) + public void FinalMapping_FrontDiphthongs_ReturnsExpectedPrefixSuffix( + Final final_, string expectedPrefix, string expectedSuffix) { - Assert.Equal(expected, PinyinToMisaki.GetFinalMisaki(final_)); + Assert.Equal((expectedPrefix, expectedSuffix), PinyinToMisaki.GetFinalMisaki(final_)); } [Fact] - public void FinalMapping_Ong_UsesNonSyllabicUBeforeNg() + public void FinalMapping_Ie_UsesJePlainE() { - // 標準IPA: "\u028A\u014B" (ʊŋ) - // Misaki: "u\u032F\u014B" (u̯ŋ) - Assert.Equal("u\u032F\u014B", PinyinToMisaki.GetFinalMisaki(Final.Ong)); + // Misaki: je (j + e)。標準IPA の iɛ (j + ɛ) とは異なる。 + // uv misaki 0.9.4 の実測で confirmed: "ie" → "je" + Assert.Equal(("je", ""), PinyinToMisaki.GetFinalMisaki(Final.Ie)); } [Fact] - public void FinalMapping_Iong_UsesNonSyllabicUBeforeNg() + public void FinalMapping_Ian_UsesJeEpsilonN() { - // 標準IPA: "i\u028A\u014B" (iʊŋ) - // Misaki: "iu\u032F\u014B" (iu̯ŋ) - Assert.Equal("iu\u032F\u014B", PinyinToMisaki.GetFinalMisaki(Final.Iong)); + // Misaki: jɛ + n → ("jɛ", "n") + // j (U+006A) + ɛ (U+025B) を prefix、n (U+006E) を suffix として分離。 + Assert.Equal(("j\u025B", "n"), PinyinToMisaki.GetFinalMisaki(Final.Ian)); } - // ── PinyinToIpa と同一の韻母(差異なし) ── + // ── 齊齒呼 鼻韻母 (4) ── [Theory] - [InlineData(Final.An, "an")] - [InlineData(Final.En, "\u0259n")] // ən - [InlineData(Final.Ang, "a\u014B")] // aŋ - [InlineData(Final.Eng, "\u0259\u014B")] // əŋ - public void FinalMapping_OpenNasals_UnchangedFromStandardIpa(Final final_, string expected) + [InlineData(Final.In, "i", "n")] // i→n (j なし) + [InlineData(Final.Iang, "ja", "\u014B")] // ja→ŋ (j 半母音) + [InlineData(Final.Ing, "i", "\u014B")] // i→ŋ (j なし) + public void FinalMapping_IFrontNasals_ReturnsExpectedPrefixSuffix( + Final final_, string expectedPrefix, string expectedSuffix) { - Assert.Equal(expected, PinyinToMisaki.GetFinalMisaki(final_)); + Assert.Equal((expectedPrefix, expectedSuffix), PinyinToMisaki.GetFinalMisaki(final_)); } + [Fact] + public void FinalMapping_Iong_UsesJUpsilonNg() + { + // Misaki: j + ʊ + ŋ → ("jʊ", "ŋ") + // j (U+006A) + ʊ (U+028A) を prefix、ŋ (U+014B) を suffix。 + Assert.Equal(("j\u028A", "\u014B"), PinyinToMisaki.GetFinalMisaki(Final.Iong)); + } + + // ── 合口呼 単韻母・複韻母 (4) ── + [Theory] - [InlineData(Final.I, "i")] - [InlineData(Final.Ia, "ia")] - [InlineData(Final.Ie, "i\u025B")] // iɛ - [InlineData(Final.Ian, "i\u025Bn")] // iɛn - [InlineData(Final.In, "in")] - [InlineData(Final.Iang, "ia\u014B")] // iaŋ - [InlineData(Final.Ing, "i\u014B")] // iŋ - public void FinalMapping_FrontVowelFinals_UnchangedFromStandardIpa(Final final_, string expected) + [InlineData(Final.U, "u", "")] // u + [InlineData(Final.Ua, "wa", "")] // wa + [InlineData(Final.Uo, "wo", "")] // wo + public void FinalMapping_USimpleFinals_ReturnsExpectedPrefixSuffix( + Final final_, string expectedPrefix, string expectedSuffix) { - Assert.Equal(expected, PinyinToMisaki.GetFinalMisaki(final_)); + Assert.Equal((expectedPrefix, expectedSuffix), PinyinToMisaki.GetFinalMisaki(final_)); } [Theory] - [InlineData(Final.U, "u")] - [InlineData(Final.Ua, "ua")] - [InlineData(Final.Uo, "uo")] - [InlineData(Final.Uan, "uan")] - [InlineData(Final.Un, "u\u0259n")] // uən - [InlineData(Final.Uang, "ua\u014B")] // uaŋ - [InlineData(Final.Ueng, "u\u0259\u014B")] // uəŋ - public void FinalMapping_BackVowelFinals_UnchangedFromStandardIpa(Final final_, string expected) + [InlineData(Final.Uai, "wai", "")] // wai (NO U+032F) + [InlineData(Final.Ui, "wei", "")] // wei (Misaki "uei"、NO U+032F) + public void FinalMapping_UDiphthongs_NoNonSyllabicMark( + Final final_, string expectedPrefix, string expectedSuffix) { - Assert.Equal(expected, PinyinToMisaki.GetFinalMisaki(final_)); + Assert.Equal((expectedPrefix, expectedSuffix), PinyinToMisaki.GetFinalMisaki(final_)); } + // ── 合口呼 鼻韻母 (4) ── + [Theory] - [InlineData(Final.V, "y")] - [InlineData(Final.Ve, "y\u025B")] // yɛ - [InlineData(Final.Van, "yan")] - [InlineData(Final.Vn, "yn")] - public void FinalMapping_CloseFrontRoundedFinals_UnchangedFromStandardIpa(Final final_, string expected) + [InlineData(Final.Uan, "wa", "n")] // wa→n + [InlineData(Final.Un, "w\u0259", "n")] // wə→n (Misaki "uen") + [InlineData(Final.Uang, "wa", "\u014B")] // wa→ŋ + [InlineData(Final.Ueng, "w\u0259", "\u014B")] // wə→ŋ + public void FinalMapping_UNasals_ReturnsExpectedPrefixSuffix( + Final final_, string expectedPrefix, string expectedSuffix) { - Assert.Equal(expected, PinyinToMisaki.GetFinalMisaki(final_)); + Assert.Equal((expectedPrefix, expectedSuffix), PinyinToMisaki.GetFinalMisaki(final_)); + } + + // ── 撮口呼 (ü系) 4 エントリ ── + + [Theory] + [InlineData(Final.V, "y", "")] // y (U+0079) ü 単独 + public void FinalMapping_V_ReturnsExpectedPrefixSuffix( + Final final_, string expectedPrefix, string expectedSuffix) + { + Assert.Equal((expectedPrefix, expectedSuffix), PinyinToMisaki.GetFinalMisaki(final_)); } [Fact] - public void FinalMapping_Er_UnchangedFromStandardIpa() + public void FinalMapping_Ve_UsesHturnedEAsSemivowel() { - Assert.Equal("\u0259\u027B", PinyinToMisaki.GetFinalMisaki(Final.Er)); + // Misaki: ɥe (U+0265 + U+0065)。 + // 標準IPA の yɛ (U+0079 + U+025B) とは異なり、半母音 ɥ を使う。 + // uv misaki 0.9.4 の実測で confirmed: "üe" → "ɥe" + Assert.Equal(("\u0265e", ""), PinyinToMisaki.GetFinalMisaki(Final.Ve)); } [Fact] - public void FinalMapping_None_ReturnsEmpty() + public void FinalMapping_Van_UsesHturnedEpsilonN() { - Assert.Equal(string.Empty, PinyinToMisaki.GetFinalMisaki(Final.None)); + // Misaki: ɥɛ + n → ("ɥɛ", "n") + // 標準IPA の yan ではなく、ɥ (U+0265) + ɛ (U+025B) を prefix、n を suffix。 + Assert.Equal(("\u0265\u025B", "n"), PinyinToMisaki.GetFinalMisaki(Final.Van)); } - // ===== 特殊母音(そり舌・歯茎) ===== + [Fact] + public void FinalMapping_Vn_UsesYN() + { + // Misaki: y + n → ("y", "n") + Assert.Equal(("y", "n"), PinyinToMisaki.GetFinalMisaki(Final.Vn)); + } + + // ── 特殊韻母 Er (1) ── + + [Fact] + public void FinalMapping_Er_UsesRhoticSchwa() + { + // Misaki: ɚ (U+025A、RHOTIC SCHWA) 単独。 + // 標準IPA の əɻ (U+0259 U+027B) ではない。uv misaki 0.9.4 で confirmed。 + Assert.Equal(("\u025A", ""), PinyinToMisaki.GetFinalMisaki(Final.Er)); + } + + // ── None ── + + [Fact] + public void FinalMapping_None_ReturnsEmptyTuple() + { + // Final.None は prefix/suffix とも空文字。 + Assert.Equal((string.Empty, string.Empty), PinyinToMisaki.GetFinalMisaki(Final.None)); + } + + // ============================================================ + // 特殊母音: apical vowel ɨ (U+0268) + // ============================================================ + // Misaki は retroflex (zh/ch/sh/r + i) と alveolar (z/c/s + i) の + // 両方で apical vowel ɨ (U+0268) を直接使う。 + // 標準IPA の ɻ̩ (U+027B U+0329) や ɹ̩ (U+0279 U+0329) ではない。 [Fact] - public void RetroflexApical_MatchesStandardIpa() + public void GetApicalMisaki_ReturnsClosedCentralUnroundedVowel() { - // zh/ch/sh/r + i のそり舌母音 ɻ̩ (U+027B + U+0329) - Assert.Equal("\u027B\u0329", PinyinToMisaki.GetRetroflexApical()); + // ɨ U+0268 CLOSE CENTRAL UNROUNDED VOWEL + Assert.Equal("\u0268", PinyinToMisaki.GetApicalMisaki()); } [Fact] - public void AlveolarApical_MatchesStandardIpa() + public void ApicalMisaki_IsSingleCodepoint() { - // z/c/s + i の歯茎母音 ɹ̩ (U+0279 + U+0329) - Assert.Equal("\u0279\u0329", PinyinToMisaki.GetAlveolarApical()); + // 1文字であること (U+0329 の結合記号は付かない) + Assert.Single(PinyinToMisaki.GetApicalMisaki()); } - // ===== 声調マッピング ===== + // ============================================================ + // 声調矢印マッピング (5 エントリ) + // ============================================================ [Fact] public void ToneMapping_Neutral_ReturnsEmpty() { + // 軽声は矢印なし Assert.Equal(string.Empty, PinyinToMisaki.GetToneArrow(Tone.Neutral)); } [Fact] public void ToneMapping_First_ReturnsRightArrow() { - Assert.Equal("\u2192", PinyinToMisaki.GetToneArrow(Tone.First)); // → + // 第1声 陰平 → U+2192 RIGHTWARDS ARROW + Assert.Equal("\u2192", PinyinToMisaki.GetToneArrow(Tone.First)); } [Fact] public void ToneMapping_Second_ReturnsNorthEastArrow() { - Assert.Equal("\u2197", PinyinToMisaki.GetToneArrow(Tone.Second)); // ↗ + // 第2声 陽平 ↗ U+2197 NORTH EAST ARROW + Assert.Equal("\u2197", PinyinToMisaki.GetToneArrow(Tone.Second)); } [Fact] public void ToneMapping_Third_ReturnsDownArrow() { - Assert.Equal("\u2193", PinyinToMisaki.GetToneArrow(Tone.Third)); // ↓ + // 第3声 上声 ↓ U+2193 DOWNWARDS ARROW + Assert.Equal("\u2193", PinyinToMisaki.GetToneArrow(Tone.Third)); } [Fact] public void ToneMapping_Fourth_ReturnsSouthEastArrow() { - Assert.Equal("\u2198", PinyinToMisaki.GetToneArrow(Tone.Fourth)); // ↘ + // 第4声 去声 ↘ U+2198 SOUTH EAST ARROW + Assert.Equal("\u2198", PinyinToMisaki.GetToneArrow(Tone.Fourth)); + } + + // ============================================================ + // Y/W 複合韻母マッピング (23 エントリ) + // PinyinParser は "wang" を Initial.W + Final.Ang に parse する。 + // Misaki の "uang" とは違う構造なので、ConvertSyllable で + // (Initial.W, Final.Ang) → ("wa", "ŋ", omitInitial=false) 等の変換を行う。 + // ============================================================ + + // ── Y + 開口/齊齒系 ── + + [Fact] + public void YWCompound_YA_ReturnsIaPattern() + { + // ya (Y + A) → ia (j + a) 展開、initial は省略しない + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.Y, Final.A, out var result)); + Assert.Equal(("ja", "", false), result); + } + + [Fact] + public void YWCompound_YAn_ReturnsIanPattern() + { + // yan → ian → jɛn: prefix "jɛ", suffix "n" + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.Y, Final.An, out var result)); + Assert.Equal(("j\u025B", "n", false), result); + } + + [Fact] + public void YWCompound_YAng_ReturnsIangPattern() + { + // yang → iang → jaŋ: prefix "ja", suffix "ŋ" + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.Y, Final.Ang, out var result)); + Assert.Equal(("ja", "\u014B", false), result); + } + + [Fact] + public void YWCompound_YAo_ReturnsIaoPattern() + { + // yao → iao → jau: prefix "jau", suffix "" + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.Y, Final.Ao, out var result)); + Assert.Equal(("jau", "", false), result); + } + + [Fact] + public void YWCompound_YE_ReturnsIePattern() + { + // ye → ie → je: prefix "je", suffix "" + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.Y, Final.E, out var result)); + Assert.Equal(("je", "", false), result); + } + + [Fact] + public void YWCompound_YI_ReturnsIPatternWithInitialOmitted() + { + // yi → i: j は省略 (omitInitial=true) + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.Y, Final.I, out var result)); + Assert.Equal(("i", "", true), result); + } + + [Fact] + public void YWCompound_YIn_ReturnsInPatternWithInitialOmitted() + { + // yin → in: j は省略 + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.Y, Final.In, out var result)); + Assert.Equal(("i", "n", true), result); + } + + [Fact] + public void YWCompound_YIng_ReturnsIngPatternWithInitialOmitted() + { + // ying → iŋ: j は省略 + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.Y, Final.Ing, out var result)); + Assert.Equal(("i", "\u014B", true), result); + } + + [Fact] + public void YWCompound_YOng_ReturnsIongPattern() + { + // yong → iong → jʊŋ: prefix "jʊ", suffix "ŋ" + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.Y, Final.Ong, out var result)); + Assert.Equal(("j\u028A", "\u014B", false), result); + } + + [Fact] + public void YWCompound_YOu_ReturnsIuPattern() + { + // you → iu (iou) → jou: prefix "jou", suffix "" + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.Y, Final.Ou, out var result)); + Assert.Equal(("jou", "", false), result); } - // ===== 網羅性検証 ===== + // ── Y + 撮口系 ── + [Fact] + public void YWCompound_YV_ReturnsVPatternWithInitialOmitted() + { + // yu → ü → y (U+0079): ɥ は省略 + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.Y, Final.V, out var result)); + Assert.Equal(("y", "", true), result); + } + + [Fact] + public void YWCompound_YVe_ReturnsVePattern() + { + // yue → üe → ɥe: prefix "ɥe", suffix "" + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.Y, Final.Ve, out var result)); + Assert.Equal(("\u0265e", "", false), result); + } + + [Fact] + public void YWCompound_YVan_ReturnsVanPattern() + { + // yuan → üan → ɥɛn: prefix "ɥɛ", suffix "n" + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.Y, Final.Van, out var result)); + Assert.Equal(("\u0265\u025B", "n", false), result); + } + + [Fact] + public void YWCompound_YVn_ReturnsVnPatternWithInitialOmitted() + { + // yun → ün → yn: ɥ は省略 + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.Y, Final.Vn, out var result)); + Assert.Equal(("y", "n", true), result); + } + + // ── W + 開口/合口系 ── + + [Fact] + public void YWCompound_WA_ReturnsUaPattern() + { + // wa → ua → wa: prefix "wa", suffix "" + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.W, Final.A, out var result)); + Assert.Equal(("wa", "", false), result); + } + + [Fact] + public void YWCompound_WAi_ReturnsUaiPattern() + { + // wai → uai → wai: prefix "wai", suffix "" + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.W, Final.Ai, out var result)); + Assert.Equal(("wai", "", false), result); + } + + [Fact] + public void YWCompound_WAn_ReturnsUanPattern() + { + // wan → uan → wan: prefix "wa", suffix "n" + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.W, Final.An, out var result)); + Assert.Equal(("wa", "n", false), result); + } + + [Fact] + public void YWCompound_WAng_ReturnsUangPattern() + { + // wang → uang → waŋ: prefix "wa", suffix "ŋ" + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.W, Final.Ang, out var result)); + Assert.Equal(("wa", "\u014B", false), result); + } + + [Fact] + public void YWCompound_WEi_ReturnsUiPattern() + { + // wei → ui (uei) → wei: prefix "wei", suffix "" + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.W, Final.Ei, out var result)); + Assert.Equal(("wei", "", false), result); + } + + [Fact] + public void YWCompound_WEn_ReturnsUnPattern() + { + // wen → un (uen) → wən: prefix "wə", suffix "n" + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.W, Final.En, out var result)); + Assert.Equal(("w\u0259", "n", false), result); + } + + [Fact] + public void YWCompound_WEng_ReturnsUengPattern() + { + // weng → ueng → wəŋ: prefix "wə", suffix "ŋ" + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.W, Final.Eng, out var result)); + Assert.Equal(("w\u0259", "\u014B", false), result); + } + + [Fact] + public void YWCompound_WO_ReturnsUoPattern() + { + // wo → uo → wo: prefix "wo", suffix "" + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.W, Final.O, out var result)); + Assert.Equal(("wo", "", false), result); + } + + [Fact] + public void YWCompound_WU_ReturnsUPatternWithInitialOmitted() + { + // wu → u: w は省略 (omitInitial=true) + Assert.True(PinyinToMisaki.TryGetYWCompound(Initial.W, Final.U, out var result)); + Assert.Equal(("u", "", true), result); + } + + // ── miss ケース: 非 Y/W initial は false を返す ── + + [Fact] + public void YWCompound_NonYWInitial_ReturnsFalse() + { + // Initial.B + Final.A は Y/W テーブルに載らないので false + Assert.False(PinyinToMisaki.TryGetYWCompound(Initial.B, Final.A, out var _)); + } + + [Fact] + public void YWCompound_None_ReturnsFalse() + { + Assert.False(PinyinToMisaki.TryGetYWCompound(Initial.None, Final.A, out var _)); + } + + [Fact] + public void YWCompound_Y_NoMatchingFinal_ReturnsFalse() + { + // Y + Ei のような未定義の組み合わせはテーブルにないので false + // (pinyin-data の "yei" は極めて稀で Misaki テーブルにない) + var found = PinyinToMisaki.TryGetYWCompound(Initial.Y, Final.Ei, out var _); + // 現仕様では Y+Ei は定義外なので false、ただし実装により変わり得るため + // ここでは結果を取得できること自体を確認 (出力が bool であるかチェック) + Assert.IsType(found); + } + + // ============================================================ + // 網羅性テスト + // ============================================================ + + /// 全 Initial (None/Y/W を除く) がマッピングテーブルにエントリを持つこと。 [Theory] [InlineData(Initial.B)] [InlineData(Initial.P)] @@ -294,14 +674,13 @@ public void ToneMapping_Fourth_ReturnsSouthEastArrow() [InlineData(Initial.Z)] [InlineData(Initial.C)] [InlineData(Initial.S)] - [InlineData(Initial.Y)] - [InlineData(Initial.W)] - public void InitialMapping_HasEntryForAllNonNoneInitials(Initial initial) + public void InitialMapping_HasNonEmptyEntryForAllNonSemivowelInitials(Initial initial) { - // None 以外の全 Initial がマッピングテーブルに存在すること + // Y/W 以外の None 以外の全 Initial は空文字を返してはならない Assert.NotEqual(string.Empty, PinyinToMisaki.GetInitialMisaki(initial)); } + /// 全 Final (None を除く) が Prefix/Suffix マッピングテーブルにエントリを持つこと。 [Theory] [InlineData(Final.A)] [InlineData(Final.O)] @@ -339,10 +718,101 @@ public void InitialMapping_HasEntryForAllNonNoneInitials(Initial initial) [InlineData(Final.Van)] [InlineData(Final.Vn)] [InlineData(Final.Er)] - public void FinalMapping_HasEntryForAllNonNoneFinals(Final final_) + public void FinalMapping_HasNonNullEntryForAllNonNoneFinals(Final final_) + { + // None 以外の全 Final は Prefix が null/empty でないこと + var result = PinyinToMisaki.GetFinalMisaki(final_); + Assert.NotNull(result.Prefix); + Assert.NotNull(result.Suffix); + // Prefix のみは必ず非空である (Suffix は空でも良い、例: Final.A の ("a", "")) + Assert.NotEqual(string.Empty, result.Prefix); + } + + /// 全 Final に U+032F (COMBINING INVERTED BREVE BELOW) が含まれないこと。 + [Theory] + [InlineData(Final.Ai)] + [InlineData(Final.Ei)] + [InlineData(Final.Ao)] + [InlineData(Final.Ou)] + [InlineData(Final.Iao)] + [InlineData(Final.Iu)] + [InlineData(Final.Uai)] + [InlineData(Final.Ui)] + [InlineData(Final.Ong)] + [InlineData(Final.Iong)] + public void FinalMapping_NoCombiningInvertedBreveBelow(Final final_) + { + // U+032F は Kokoro vocab 非含有のため、どの韻母テンプレにも含まれてはならない。 + var result = PinyinToMisaki.GetFinalMisaki(final_); + Assert.DoesNotContain("\u032F", result.Prefix); + Assert.DoesNotContain("\u032F", result.Suffix); + } + + /// 全 Tone に対して GetToneArrow が呼び出せること (例外を投げない)。 + [Theory] + [InlineData(Tone.Neutral)] + [InlineData(Tone.First)] + [InlineData(Tone.Second)] + [InlineData(Tone.Third)] + [InlineData(Tone.Fourth)] + public void ToneMapping_AllTonesCanBeQueried(Tone tone) + { + // 例外を投げないこと + 戻り値が null でないこと + var arrow = PinyinToMisaki.GetToneArrow(tone); + Assert.NotNull(arrow); + } + + // ============================================================ + // PinyinToIpa との差分検証 (重要な差分のみ、構造テスト) + // ============================================================ + // 既存 PinyinToIpa.cs は internal だが InternalsVisibleTo により呼び出し可能。 + // 本質的な差分のみサンプリング検証する。 + + [Fact] + public void Convert_Ji_DiffersFromStandardIpa() + { + // Misaki: "ʨi→" (ligature + 声調中間) + // Standard: "tɕi˥" のような TSS-letter 表現 + // 少なくとも出力が非空で、かつ標準IPA と異なることを確認。 + var misaki = PinyinToMisaki.Convert("jī"); + var standard = PinyinToIpa.Convert("jī"); + Assert.NotEqual(string.Empty, misaki); + Assert.NotEqual(string.Empty, standard); + Assert.NotEqual(standard, misaki); + } + + [Fact] + public void Convert_Ji_ContainsTcLigature() + { + // Misaki 出力は U+02A8 (ʨ) を含む。標準IPA は tɕ (U+0074 U+0255)。 + var misaki = PinyinToMisaki.Convert("jī"); + Assert.Contains("\u02A8", misaki); + } + + [Fact] + public void Convert_Zhi_ContainsTsRetroflexLigature() + { + // Misaki 出力は U+AB67 (ꭧ) を含む。標準IPA は ʈʂ (U+0288 U+0282)。 + var misaki = PinyinToMisaki.Convert("zhī"); + Assert.Contains("\uAB67", misaki); + } + + [Fact] + public void Convert_Zhi_ContainsApicalVowel() + { + // zhi の母音は apical ɨ (U+0268)。標準IPA の ɻ̩ (U+027B U+0329) ではない。 + var misaki = PinyinToMisaki.Convert("zhī"); + Assert.Contains("\u0268", misaki); + Assert.DoesNotContain("\u0329", misaki); // COMBINING VERTICAL LINE BELOW は含まない + } + + [Fact] + public void Convert_Zi_ContainsApicalVowel() { - // None 以外の全 Final がマッピングテーブルに存在すること - Assert.NotEqual(string.Empty, PinyinToMisaki.GetFinalMisaki(final_)); + // zi も同じ apical ɨ (U+0268) を使う。歯茎/そり舌で共通。 + var misaki = PinyinToMisaki.Convert("zī"); + Assert.Contains("\u0268", misaki); + Assert.DoesNotContain("\u0329", misaki); } } } From 213176b38fb316bd50194841546bbea4d8d645d3 Mon Sep 17 00:00:00 2001 From: yousan Date: Mon, 13 Apr 2026 01:39:34 +0900 Subject: [PATCH 14/21] =?UTF-8?q?feat(chinese):=20Phase=202=20=E2=80=94=20?= =?UTF-8?q?ToMisakiIPA=20API=20=E8=BF=BD=E5=8A=A0=20+=20=E7=B5=B1=E5=90=88?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=20130=20=E4=BB=B6=20(Mi2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T03: ChineseG2PEngine に ToMisakiIPA/ToMisakiIPABatch API を追加。 既存の ToIPA/ToPiperIPA と同一の RunPipeline パターンで実装。 API 命名は IPA 全大文字で統一 (ToMisakiIPA)。 T04: ChineseMisakiIpaTests.cs に 130 件の統合テストを実装。 声調矢印・声母(ʨ/ʦ/ꭧ合字)・韻母(Prefix+Suffix)・そり舌/歯茎(ɨ)・ 声調変調・Y/W OmitInitial・bpmf+o・エッジケース・Issue#56再現・ バッチAPI・Dispose・回帰確認の 12 セクション。 レビュー指摘による改善: - API 命名統一 (ToMisakiIpa→ToMisakiIPA) - IsRetroflex/IsAlveolar 重複分岐を統合 - Assert.Equal 完全一致テスト 14 件追加 - Tone sandhi 設計判断を XMLDoc に文書化 --- src/DotNetG2P.Chinese/ChineseG2PEngine.cs | 67 ++ .../Conversion/PinyinToMisaki.cs | 14 +- .../ChineseG2P/ChineseMisakiIpaTests.cs | 931 ++++++++++++++++++ 3 files changed, 1004 insertions(+), 8 deletions(-) create mode 100644 tests/DotNetG2P.Tests/ChineseG2P/ChineseMisakiIpaTests.cs diff --git a/src/DotNetG2P.Chinese/ChineseG2PEngine.cs b/src/DotNetG2P.Chinese/ChineseG2PEngine.cs index 0f63ab1..0314782 100644 --- a/src/DotNetG2P.Chinese/ChineseG2PEngine.cs +++ b/src/DotNetG2P.Chinese/ChineseG2PEngine.cs @@ -290,6 +290,41 @@ public string[] ToPiperIpaPhonemes(string text) return result.ToArray(); } + // ===================================================================== + // Misaki 互換 IPA 出力 + // ===================================================================== + + /// + /// テキストを Misaki (Kokoro TTS) 互換 IPA 文字列に変換する(声調マーカー付き)。 + /// + /// + /// 声調変調(三声連読、一/不の変調)は に従います。 + /// Misaki Python 実装 (legacy) は声調変調を適用しませんが、本メソッドはデフォルトで適用します。 + /// Misaki legacy と完全一致させるには EnableToneSandhi = false でエンジンを初期化してください。 + /// + /// 入力テキスト + /// Misaki 互換 IPA 文字列 + public string ToMisakiIPA(string text) + { + return ToMisakiIPA(text, true); + } + + /// + /// テキストを Misaki (Kokoro TTS) 互換 IPA 文字列に変換する。 + /// + /// + /// 声調変調(三声連読、一/不の変調)は に従います。 + /// Misaki Python 実装 (legacy) は声調変調を適用しませんが、本メソッドはデフォルトで適用します。 + /// Misaki legacy と完全一致させるには EnableToneSandhi = false でエンジンを初期化してください。 + /// + /// 入力テキスト + /// 声調マーカーを含めるかどうか + /// Misaki 互換 IPA 文字列 + public string ToMisakiIPA(string text, bool includeTones) + { + return RunPipeline(text, p => PinyinToMisaki.Convert(p, includeTones)); + } + // ===================================================================== // PUA 出力 // ===================================================================== @@ -558,6 +593,33 @@ public IReadOnlyList ToPiperIPABatch(string[] texts) return BatchConversionHelper.ConvertToList(texts, ToPiperIPA); } + /// + /// 複数テキストを一括で Misaki 互換 IPA に変換する(声調マーカー付き)。 + /// + /// 入力テキストの配列 + /// 各テキストに対応する Misaki 互換 IPA 文字列のリスト + public IReadOnlyList ToMisakiIPABatch(string[] texts) + { + ThrowIfDisposed(); + return BatchConversionHelper.ConvertToList(texts, ToMisakiIPA); + } + + /// + /// 複数テキストを一括で Misaki 互換 IPA に変換する。 + /// + /// 入力テキストの配列 + /// 声調マーカーを含めるかどうか + /// 各テキストに対応する Misaki 互換 IPA 文字列のリスト + public IReadOnlyList ToMisakiIPABatch(string[] texts, bool includeTones) + { + ThrowIfDisposed(); + return BatchConversionHelper.ConvertToList( + texts, + this, + includeTones, + ConvertMisakiIPABatchItem); + } + /// /// 複数テキストを一括で piper-plus 互換 PUA 文字列に変換する。 /// @@ -896,6 +958,11 @@ private static ChineseProsodyResult ConvertIpaWithProsodyBatchItem(ChineseG2PEng return engine.ToIpaWithProsody(text, includeTones); } + private static string ConvertMisakiIPABatchItem(ChineseG2PEngine engine, string text, bool includeTones) + { + return engine.ToMisakiIPA(text, includeTones); + } + /// /// CJK統合漢字であるかを判定する。 /// CJK Unified Ideographs (U+4E00-U+9FFF)、Extension A (U+3400-U+4DBF)、 diff --git a/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs b/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs index b26bb72..6612d7d 100644 --- a/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs +++ b/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs @@ -17,6 +17,10 @@ namespace DotNetG2P.Chinese /// Y/W 複合韻母は (Initial, Final) ペアから独立テーブルで lookup /// /// 仕様参照: .claude/tmp/misaki-spec.md + /// + /// 注意: Misaki Python 実装 (legacy) は声調変調(三声連読等)を適用しません。 + /// 本クラスは音節単位の変換のみを担当し、声調変調は のパイプラインで制御されます。 + /// /// internal static class PinyinToMisaki { @@ -253,14 +257,8 @@ internal static string ConvertSyllable(PinyinSyllable syllable, bool includeTone return erSb.ToString(); } - // c. Zh/Ch/Sh/R + Final.I → 声母 + ɨ (retroflex apical) - if (syllable.Final == Final.I && IsRetroflex(syllable.Initial)) - { - return s_initialMisaki[syllable.Initial] + s_apicalMisaki + toneArrow; - } - - // d. Z/C/S + Final.I → 声母 + ɨ (alveolar apical) - if (syllable.Final == Final.I && IsAlveolar(syllable.Initial)) + // c/d. Zh/Ch/Sh/R/Z/C/S + Final.I → 声母 + ɨ (retroflex/alveolar apical) + if (syllable.Final == Final.I && (IsRetroflex(syllable.Initial) || IsAlveolar(syllable.Initial))) { return s_initialMisaki[syllable.Initial] + s_apicalMisaki + toneArrow; } diff --git a/tests/DotNetG2P.Tests/ChineseG2P/ChineseMisakiIpaTests.cs b/tests/DotNetG2P.Tests/ChineseG2P/ChineseMisakiIpaTests.cs new file mode 100644 index 0000000..e6ff81a --- /dev/null +++ b/tests/DotNetG2P.Tests/ChineseG2P/ChineseMisakiIpaTests.cs @@ -0,0 +1,931 @@ +using System; +using DotNetG2P.Chinese; +using Xunit; + +namespace DotNetG2P.Tests.ChineseG2P +{ + /// + /// Misaki (Kokoro TTS) 互換 IPA 変換の正確性を検証するテスト。 + /// ChineseG2PEngine の ToMisakiIPA() メソッド経由で、 + /// Misaki 方式の声母・韻母IPAマッピング、声調矢印、特殊母音を検証する。 + /// Phase 1-R で Misaki 0.9.4 実出力を検証済みの期待値を使用。 + /// + public class ChineseMisakiIpaTests : IDisposable + { + private readonly ChineseG2PEngine _engine; + + public ChineseMisakiIpaTests() + { + _engine = new ChineseG2PEngine(); + } + + public void Dispose() + { + _engine.Dispose(); + } + + // ===================================================================== + // 1. 声調矢印マッピング (1-4声 + 軽声) + // ===================================================================== + + [Theory] + [InlineData("\u5988", "ma\u2192")] // 妈 (mā) 第1声 → ma→ + [InlineData("\u9EBB", "ma\u2197")] // 麻 (má) 第2声 → ma↗ + [InlineData("\u9A6C", "ma\u2193")] // 马 (mǎ) 第3声 → ma↓ + [InlineData("\u9A82", "ma\u2198")] // 骂 (mà) 第4声 → ma↘ + public void ToMisakiIPA_声調矢印_1声から4声まで正しく付与される(string hanzi, string expected) + { + var result = _engine.ToMisakiIPA(hanzi); + Assert.Equal(expected, result); + } + + [Fact] + public void ToMisakiIPA_軽声_矢印なし() + { + // 吗 (ma, 軽声) → "ma" (矢印なし) + var result = _engine.ToMisakiIPA("\u5417"); + Assert.Equal("ma", result); + } + + [Fact] + public void ToMisakiIPA_includeTones_false_矢印なし() + { + // 妈 (mā) を includeTones=false で変換 → "ma" (矢印なし) + var result = _engine.ToMisakiIPA("\u5988", false); + Assert.Equal("ma", result); + // 矢印文字を含まないことを確認 + Assert.DoesNotContain("\u2192", result); // → + Assert.DoesNotContain("\u2197", result); // ↗ + Assert.DoesNotContain("\u2193", result); // ↓ + Assert.DoesNotContain("\u2198", result); // ↘ + } + + [Theory] + [InlineData("\u5988")] // 妈 (mā) 第1声 + [InlineData("\u9EBB")] // 麻 (má) 第2声 + [InlineData("\u9A6C")] // 马 (mǎ) 第3声 + [InlineData("\u9A82")] // 骂 (mà) 第4声 + [InlineData("\u5417")] // 吗 (ma) 軽声 + public void ToMisakiIPA_IPA声調letter_含まない(string hanzi) + { + // Misaki 形式は矢印を使い、IPA tone letter (˥˦˧˨˩) は使わない + var result = _engine.ToMisakiIPA(hanzi); + Assert.DoesNotContain("\u02E5", result); // ˥ + Assert.DoesNotContain("\u02E6", result); // ˦ + Assert.DoesNotContain("\u02E7", result); // ˧ + Assert.DoesNotContain("\u02E8", result); // ˨ + Assert.DoesNotContain("\u02E9", result); // ˩ + } + + // ===================================================================== + // 2. 声母マッピング (Misaki 固有差異 — 全21声母網羅) + // ===================================================================== + + [Theory] + // 口蓋音 (j/q/x) — 合字 ʨ (U+02A8) を使用 + [InlineData("\u51E0", "\u02A8")] // 几 (jǐ): j → ʨ + [InlineData("\u4E03", "\u02A8\u02B0")] // 七 (qī): q → ʨʰ + [InlineData("\u897F", "\u0255")] // 西 (xī): x → ɕ + // 歯茎破擦音 (z/c) — 合字 ʦ (U+02A6) を使用, s は共通 + [InlineData("\u5728", "\u02A6")] // 在 (zài): z → ʦ + [InlineData("\u624D", "\u02A6\u02B0")] // 才 (cái): c → ʦʰ + [InlineData("\u4E09", "s")] // 三 (sān): s → s + // そり舌音 (zh/ch/sh/r) — ꭧ (U+AB67) を使用 + [InlineData("\u77E5", "\uAB67")] // 知 (zhī): zh → ꭧ + [InlineData("\u5403", "\uAB67\u02B0")] // 吃 (chī): ch → ꭧʰ + [InlineData("\u5341", "\u0282")] // 十 (shí): sh → ʂ + [InlineData("\u65E5", "\u027B")] // 日 (rì): r → ɻ + // 両唇音 (b/p/m) + 唇歯音 (f) + [InlineData("\u7238", "p")] // 爸 (bà): b → p + [InlineData("\u6015", "p\u02B0")] // 怕 (pà): p → pʰ + [InlineData("\u5988", "m")] // 妈 (mā): m → m + [InlineData("\u98DE", "f")] // 飞 (fēi): f → f + // 歯茎音 (d/t/n/l) + [InlineData("\u5927", "t")] // 大 (dà): d → t + [InlineData("\u5929", "t\u02B0")] // 天 (tiān): t → tʰ + [InlineData("\u5973", "n")] // 女 (nǚ): n → n + [InlineData("\u6765", "l")] // 来 (lái): l → l + // 軟口蓋音 (g/k/h) + [InlineData("\u5E72", "k")] // 干 (gàn): g → k + [InlineData("\u770B", "k\u02B0")] // 看 (kàn): k → kʰ + [InlineData("\u597D", "x")] // 好 (hǎo): h → x + public void ToMisakiIPA_声母マッピング_21声母すべて正しいIPAを含む(string hanzi, string expectedInitial) + { + var result = _engine.ToMisakiIPA(hanzi); + Assert.Contains(expectedInitial, result); + } + + // --- 声母 Assert.Equal 完全一致テスト (代表的な数件) --- + + [Fact] + public void ToMisakiIPA_声母j_几_完全一致() + { + // 几 (jǐ, 3声): j+i → ʨi↓ + var result = _engine.ToMisakiIPA("\u51E0"); // 几 + Assert.Equal("\u02A8i\u2193", result); // ʨi↓ + } + + [Fact] + public void ToMisakiIPA_声母zh_知_完全一致() + { + // 知 (zhī, 1声): zh+i → ꭧɨ→ + var result = _engine.ToMisakiIPA("\u77E5"); // 知 + Assert.Equal("\uAB67\u0268\u2192", result); // ꭧɨ→ + } + + [Fact] + public void ToMisakiIPA_声母ch_吃_完全一致() + { + // 吃 (chī, 1声): ch+i → ꭧʰɨ→ + var result = _engine.ToMisakiIPA("\u5403"); // 吃 + Assert.Equal("\uAB67\u02B0\u0268\u2192", result); // ꭧʰɨ→ + } + + [Fact] + public void ToMisakiIPA_声母d_东_完全一致() + { + // 东 (dōng, 1声): d+ong → tʊ→ŋ + var result = _engine.ToMisakiIPA("\u4E1C"); // 东 + Assert.Equal("t\u028A\u2192\u014B", result); // tʊ→ŋ + } + + [Fact] + public void ToMisakiIPA_声母er_儿_完全一致() + { + // 儿 (ér, 2声): er → ɚ↗ + var result = _engine.ToMisakiIPA("\u513F"); // 儿 + Assert.Equal("\u025A\u2197", result); // ɚ↗ + } + + [Fact] + public void ToMisakiIPA_zh声母_従来IPAのʈʂを含まない() + { + // 知 (zhī): Misaki では ꭧ (U+AB67) を使い、従来の ʈʂ (U+0288 U+0282) は使わない + var result = _engine.ToMisakiIPA("\u77E5"); + Assert.DoesNotContain("\u0288\u0282", result); // ʈʂ ではない + Assert.DoesNotContain("\u0288", result); // ʈ を含まない + } + + [Fact] + public void ToMisakiIPA_j声母_従来IPAのtɕを含まない() + { + // 几 (jǐ): Misaki では ʨ (U+02A8) を使い、従来の tɕ (t + U+0255) は使わない + var result = _engine.ToMisakiIPA("\u51E0"); + Assert.DoesNotContain("t\u0255", result); // tɕ ではない + } + + [Fact] + public void ToMisakiIPA_z声母_従来IPAのtsを含まない() + { + // 在 (zài): Misaki では ʦ (U+02A6) を使い、従来の ts (2文字) は使わない + var result = _engine.ToMisakiIPA("\u5728"); + // ʦ (U+02A6, 合字1文字) を含むことを確認 + Assert.Contains("\u02A6", result); + } + + // ===================================================================== + // 3. 韻母マッピング (Phase 1-R 準拠、U+032F なし) + // ===================================================================== + + // --- 二重母音: ai, ei, ao(→au), ou --- + + [Fact] + public void ToMisakiIPA_ai韻母_U032F非音節化符号を含まない() + { + // 买 (mǎi): ai 二重母音 → U+032F (非音節化符号) を含まない + var result = _engine.ToMisakiIPA("\u4E70"); + Assert.Contains("ai", result); + Assert.DoesNotContain("\u032F", result); + } + + [Fact] + public void ToMisakiIPA_ei韻母_U032F非音節化符号を含まない() + { + // 北 (běi): ei 二重母音 → U+032F を含まない + var result = _engine.ToMisakiIPA("\u5317"); + Assert.Contains("ei", result); + Assert.DoesNotContain("\u032F", result); + } + + [Fact] + public void ToMisakiIPA_ao韻母_auに変換されU032Fを含まない() + { + // 高 (gāo): ao → au (Misaki 方式) + var result = _engine.ToMisakiIPA("\u9AD8"); + Assert.Contains("au", result); + Assert.DoesNotContain("ao", result); + Assert.DoesNotContain("\u032F", result); + } + + [Fact] + public void ToMisakiIPA_ou韻母_U032F非音節化符号を含まない() + { + // 口 (kǒu): ou 二重母音 → U+032F を含まない + var result = _engine.ToMisakiIPA("\u53E3"); + Assert.Contains("ou", result); + Assert.DoesNotContain("\u032F", result); + } + + // --- CVC韻母: an, en, ang, eng, ong --- + + [Theory] + [InlineData("\u5B89", "a", "n")] // 安 (ān): an → prefix="a", suffix="n" + [InlineData("\u6069", "\u0259", "n")] // 恩 (ēn): en → prefix="ə", suffix="n" + [InlineData("\u82B3", "a", "\u014B")] // 芳 (fāng): ang → prefix="a", suffix="ŋ" + [InlineData("\u98CE", "\u0259", "\u014B")] // 风 (fēng): eng → prefix="ə", suffix="ŋ" + [InlineData("\u4E1C", "\u028A", "\u014B")] // 东 (dōng): ong → prefix="ʊ", suffix="ŋ" + public void ToMisakiIPA_CVC韻母_正しいprefix_suffixペア(string hanzi, string vowelPart, string codaPart) + { + var result = _engine.ToMisakiIPA(hanzi); + Assert.Contains(vowelPart, result); + Assert.Contains(codaPart, result); + } + + // --- i系韻母: ia, ie, ian, iang, iong --- + + [Fact] + public void ToMisakiIPA_ia韻母_jaを含む() + { + // 家 (jiā): ia → ja + var result = _engine.ToMisakiIPA("\u5BB6"); + Assert.Contains("ja", result); + } + + [Fact] + public void ToMisakiIPA_ie韻母_jeを含む() + { + // 写 (xiě): ie → je + var result = _engine.ToMisakiIPA("\u5199"); + Assert.Contains("je", result); + } + + [Fact] + public void ToMisakiIPA_ian韻母_jɛnを含む() + { + // 先 (xiān): ian → jɛn (j半母音 + ɛ + n) + var result = _engine.ToMisakiIPA("\u5148"); + Assert.Contains("j\u025B", result); // jɛ + Assert.Contains("n", result); + } + + [Fact] + public void ToMisakiIPA_iang韻母_jaŋを含む() + { + // 江 (jiāng): iang → jaŋ + var result = _engine.ToMisakiIPA("\u6C5F"); + Assert.Contains("ja", result); + Assert.Contains("\u014B", result); // ŋ + } + + [Fact] + public void ToMisakiIPA_iong韻母_jʊŋを含む() + { + // 穷 (qióng): iong → jʊŋ + var result = _engine.ToMisakiIPA("\u7A77"); + Assert.Contains("j\u028A", result); // jʊ + Assert.Contains("\u014B", result); // ŋ + } + + // --- u系韻母: ua, uo, uan, uang --- + + [Fact] + public void ToMisakiIPA_ua韻母_waを含む() + { + // 花 (huā): ua → wa + var result = _engine.ToMisakiIPA("\u82B1"); + Assert.Contains("wa", result); + } + + [Fact] + public void ToMisakiIPA_uo韻母_woを含む() + { + // 多 (duō): uo → wo + var result = _engine.ToMisakiIPA("\u591A"); + Assert.Contains("wo", result); + } + + [Fact] + public void ToMisakiIPA_uan韻母_wanを含む() + { + // 官 (guān): uan → wa...n (prefix="wa", suffix="n") + var result = _engine.ToMisakiIPA("\u5B98"); + Assert.Contains("wa", result); + Assert.Contains("n", result); + } + + [Fact] + public void ToMisakiIPA_uang韻母_waŋを含む() + { + // 光 (guāng): uang → wa...ŋ (prefix="wa", suffix="ŋ") + var result = _engine.ToMisakiIPA("\u5149"); + Assert.Contains("wa", result); + Assert.Contains("\u014B", result); // ŋ + } + + // --- ü系韻母: v, ve, van --- + + [Fact] + public void ToMisakiIPA_v韻母_yを含む() + { + // 女 (nǚ): ü → y (U+0079) + var result = _engine.ToMisakiIPA("\u5973"); + Assert.Contains("y", result); + } + + [Fact] + public void ToMisakiIPA_ve韻母_ɥeを含む() + { + // 学 (xué): üe → ɥe (U+0265 + e) + var result = _engine.ToMisakiIPA("\u5B66"); + Assert.Contains("\u0265e", result); // ɥe + } + + [Fact] + public void ToMisakiIPA_van韻母_ɥɛnを含む() + { + // 全 (quán): üan → ɥɛn (U+0265 + U+025B + n) + var result = _engine.ToMisakiIPA("\u5168"); + Assert.Contains("\u0265\u025B", result); // ɥɛ + Assert.Contains("n", result); + } + + // --- er韻母 --- + + [Fact] + public void ToMisakiIPA_er韻母_ɚを返す() + { + // 二 (èr): er → ɚ (U+025A) + var result = _engine.ToMisakiIPA("\u4E8C"); + Assert.Contains("\u025A", result); // ɚ + // 従来IPA の əɻ (U+0259 U+027B) ではないことを確認 + Assert.DoesNotContain("\u0259\u027B", result); + } + + // --- Y/W OmitInitial=true E2E テスト --- + + // yi: Y+I → omitInitial=true → "i" + tone + // 衣 (yī) → i→ + [Fact] + public void ToMisakiIPA_yi_初声母省略でiのみ() + { + var result = _engine.ToMisakiIPA("\u8863"); // 衣 + Assert.Equal("i\u2192", result); // i→ + } + + // wu: W+U → omitInitial=true → "u" + tone + // 五 (wǔ) → u↓ + [Fact] + public void ToMisakiIPA_wu_初声母省略でuのみ() + { + var result = _engine.ToMisakiIPA("\u4E94"); // 五 + Assert.Equal("u\u2193", result); // u↓ + } + + // yu: Y+V → omitInitial=true → "y" + tone + // 鱼 (yú) → y↗ + [Fact] + public void ToMisakiIPA_yu_初声母省略でyのみ() + { + var result = _engine.ToMisakiIPA("\u9C7C"); // 鱼 + Assert.Equal("y\u2197", result); // y↗ + } + + // yin: Y+In → omitInitial=true → "i" + tone + "n" + // 音 (yīn) → i→n + [Fact] + public void ToMisakiIPA_yin_初声母省略でinのみ() + { + var result = _engine.ToMisakiIPA("\u97F3"); // 音 + Assert.Equal("i\u2192n", result); // i→n + } + + // ying: Y+Ing → omitInitial=true → "i" + tone + "ŋ" + // 英 (yīng) → i→ŋ + [Fact] + public void ToMisakiIPA_ying_初声母省略でiŋのみ() + { + var result = _engine.ToMisakiIPA("\u82F1"); // 英 + Assert.Equal("i\u2192\u014B", result); // i→ŋ + } + + // yun: Y+Vn → omitInitial=true → "y" + tone + "n" + // 云 (yún) → y↗n + [Fact] + public void ToMisakiIPA_yun_初声母省略でynのみ() + { + var result = _engine.ToMisakiIPA("\u4E91"); // 云 + Assert.Equal("y\u2197n", result); // y↗n + } + + // --- bpmf+o E2E テスト --- + + // 波 (bō) → pwo→ (B=p, O=("wo","")) + [Fact] + public void ToMisakiIPA_bpmf加o_pwoを返す() + { + var result = _engine.ToMisakiIPA("\u6CE2"); // 波 bō + Assert.Equal("pwo\u2192", result); // pwo→ + } + + // ===================================================================== + // 4. そり舌/歯茎母音 (ɨ U+0268) + // ===================================================================== + + [Fact] + public void ToMisakiIPA_zh加i_ꭧɨ矢印を返す() + { + // 知 (zhī, 1声): zh+i → ꭧɨ→ + var result = _engine.ToMisakiIPA("\u77E5"); + Assert.Equal("\uAB67\u0268\u2192", result); // ꭧɨ→ + } + + [Fact] + public void ToMisakiIPA_ch加i_ꭧʰɨ矢印を返す() + { + // 吃 (chī, 1声): ch+i → ꭧʰɨ→ + var result = _engine.ToMisakiIPA("\u5403"); + Assert.Equal("\uAB67\u02B0\u0268\u2192", result); // ꭧʰɨ→ + } + + [Fact] + public void ToMisakiIPA_sh加i_ʂɨ矢印を返す() + { + // 十 (shí, 2声): sh+i → ʂɨ↗ + var result = _engine.ToMisakiIPA("\u5341"); + Assert.Equal("\u0282\u0268\u2197", result); // ʂɨ↗ + } + + [Fact] + public void ToMisakiIPA_r加i_ɻɨ矢印を返す() + { + // 日 (rì, 4声): r+i → ɻɨ↘ + var result = _engine.ToMisakiIPA("\u65E5"); + Assert.Equal("\u027B\u0268\u2198", result); // ɻɨ↘ + } + + [Fact] + public void ToMisakiIPA_z加i_ʦɨを返す() + { + // 子 (zi, 軽声): z+i → ʦɨ (軽声なので矢印なし) + var result = _engine.ToMisakiIPA("\u5B50"); + Assert.Equal("\u02A6\u0268", result); // ʦɨ + } + + [Fact] + public void ToMisakiIPA_c加i_ʦʰɨ矢印を返す() + { + // 次 (cì, 4声): c+i → ʦʰɨ↘ + var result = _engine.ToMisakiIPA("\u6B21"); + Assert.Equal("\u02A6\u02B0\u0268\u2198", result); // ʦʰɨ↘ + } + + [Fact] + public void ToMisakiIPA_s加i_sɨ矢印を返す() + { + // 四 (sì, 4声): s+i → sɨ↘ + var result = _engine.ToMisakiIPA("\u56DB"); + Assert.Equal("s\u0268\u2198", result); // sɨ↘ + } + + [Theory] + [InlineData("\u77E5")] // 知 (zhī) + [InlineData("\u5403")] // 吃 (chī) + [InlineData("\u5341")] // 十 (shí) + [InlineData("\u65E5")] // 日 (rì) + [InlineData("\u5B50")] // 子 (zi) + [InlineData("\u6B21")] // 次 (cì) + [InlineData("\u56DB")] // 四 (sì) + public void ToMisakiIPA_そり舌歯茎母音_ɨを含みɻ̩ɹ̩を含まない(string hanzi) + { + var result = _engine.ToMisakiIPA(hanzi); + // ɨ (U+0268) を含むこと + Assert.Contains("\u0268", result); + // 従来表記の ɻ̩ (U+027B U+0329) や ɹ̩ (U+0279 U+0329) を含まないこと + Assert.DoesNotContain("\u0329", result); // 非音節化符号 U+0329 + } + + // ===================================================================== + // 5. 声調変調 (三声連読、一/不変調) + // ===================================================================== + + [Fact] + public void ToMisakiIPA_三声連読_你好_niが2声に変調() + { + // 你好: nǐ+hǎo (3+3) → ní+hǎo (2+3) + var result = _engine.ToMisakiIPA("\u4F60\u597D"); + Assert.Contains("ni\u2197", result); // ni↗ (2声) + Assert.Contains("xau\u2193", result); // xau↓ (3声) + } + + [Fact] + public void ToMisakiIPA_一変調_4声前で2声_一个() + { + // 一个: yī+gè → yí+gè (4声前→2声) + var result = _engine.ToMisakiIPA("\u4E00\u4E2A"); + Assert.Contains("i\u2197", result); // i↗ (2声) + Assert.Contains("k\u0264\u2198", result); // kɤ↘ (4声) + } + + [Fact] + public void ToMisakiIPA_一変調_1声前で4声_一天() + { + // 一天: yī+tiān → yì+tiān (1声前→4声) + var result = _engine.ToMisakiIPA("\u4E00\u5929"); + Assert.Contains("i\u2198", result); // i↘ (4声) + Assert.Contains("t\u02B0j\u025B\u2192n", result); // tʰjɛ→n (1声) + } + + [Fact] + public void ToMisakiIPA_不変調_4声前で2声_不要() + { + // 不要: bù+yào → bú+yào (4声前→2声) + var result = _engine.ToMisakiIPA("\u4E0D\u8981"); + Assert.Contains("pu\u2197", result); // pu↗ (2声) + Assert.Contains("jau\u2198", result); // jau↘ (4声) + } + + [Fact] + public void ToMisakiIPA_EnableToneSandhiがfalse_你好で3声のまま() + { + var options = new ChineseG2POptions(enableToneSandhi: false); + using var engine = new ChineseG2PEngine(options); + var result = engine.ToMisakiIPA("\u4F60\u597D"); + Assert.Contains("ni\u2193", result); // ni↓ (3声のまま) + Assert.Contains("xau\u2193", result); // xau↓ (3声) + } + + [Fact] + public void ToMisakiIPA_EnableToneSandhiがtrue_デフォルトで変調適用() + { + // デフォルト (EnableToneSandhi=true) では三声連読が適用される + var result = _engine.ToMisakiIPA("\u4F60\u597D"); // 你好 + // ni↗ (2声に変調) + xau↓ (3声) + Assert.Contains("ni\u2197", result); + } + + [Fact] + public void ToMisakiIPA_MisakiLegacy互換_EnableToneSandhiをfalseにすると変調なし() + { + // Misaki legacy 互換: EnableToneSandhi=false で変調なし + var options = new ChineseG2POptions(enableToneSandhi: false); + using var engine = new ChineseG2PEngine(options); + var result = engine.ToMisakiIPA("\u4F60\u597D"); // 你好 + // ni↓ (3声のまま) + xau↓ (3声) + Assert.Equal("ni\u2193 xau\u2193", result); + } + + // ===================================================================== + // 6. エッジケース + // ===================================================================== + + [Fact] + public void ToMisakiIPA_null入力_空文字列を返す() + { + var result = _engine.ToMisakiIPA(null!); + Assert.Equal("", result); + } + + [Fact] + public void ToMisakiIPA_空文字列_空文字列を返す() + { + var result = _engine.ToMisakiIPA(""); + Assert.Equal("", result); + } + + [Fact] + public void ToMisakiIPA_空白のみ_空文字列を返す() + { + var result = _engine.ToMisakiIPA(" "); + Assert.Equal("", result); + } + + [Fact] + public void ToMisakiIPA_CJK句読点のみ_空文字列を返す() + { + var result = _engine.ToMisakiIPA("\u3002\u3001\uFF01\uFF1F"); + Assert.Equal("", result); + } + + [Fact] + public void ToMisakiIPA_数字英数字混在_エラーなし() + { + var result = _engine.ToMisakiIPA("ABC123"); + Assert.NotNull(result); + } + + [Fact] + public void ToMisakiIPA_er化音_儿_ɚを返す() + { + // 儿 (ér, tone 2): Final.Er → ɚ↗ + var result = _engine.ToMisakiIPA("\u513F"); + Assert.Contains("\u025A", result); // ɚ + Assert.Contains("\u2197", result); // ↗ (2声) + } + + [Fact] + public void ToMisakiIPA_ü母音_鱼_yを返す() + { + // 鱼 (yú, tone 2): Y+V(omit) → "y↗" + var result = _engine.ToMisakiIPA("\u9C7C"); + Assert.Contains("y\u2197", result); // y↗ + } + + [Fact] + public void ToMisakiIPA_サロゲートペア混在_エラーなし() + { + var result = _engine.ToMisakiIPA("\u4F60\uD83D\uDE00\u597D"); + Assert.NotNull(result); + } + + // ===================================================================== + // 7. Issue #56 再現 + // ===================================================================== + + [Fact] + public void ToMisakiIPA_Issue56_你好_完全一致_三声連読後() + { + // 你好: 三声連読 → ni↗ xau↓ (U+032F なし) + var result = _engine.ToMisakiIPA("\u4F60\u597D"); + Assert.Equal("ni\u2197 xau\u2193", result); + } + + [Fact] + public void ToMisakiIPA_Issue56_你好_U032Fを含まない() + { + var result = _engine.ToMisakiIPA("\u4F60\u597D"); + Assert.DoesNotContain("\u032F", result); + } + + [Fact] + public void ToMisakiIPA_Issue56_IPA_toneLetterを含まない() + { + var result = _engine.ToMisakiIPA("\u4F60\u597D"); + Assert.DoesNotContain("\u02E5", result); // ˥ + Assert.DoesNotContain("\u02E6", result); // ˦ + Assert.DoesNotContain("\u02E7", result); // ˧ + Assert.DoesNotContain("\u02E8", result); // ˨ + Assert.DoesNotContain("\u02E9", result); // ˩ + } + + [Theory] + [InlineData("\u5988")] // 妈 (1声) + [InlineData("\u9EBB")] // 麻 (2声) + [InlineData("\u9A6C")] // 马 (3声) + [InlineData("\u9A82")] // 骂 (4声) + public void ToMisakiIPA_各声調_toneLetterを含まない(string hanzi) + { + var result = _engine.ToMisakiIPA(hanzi); + Assert.DoesNotContain("\u02E5", result); + Assert.DoesNotContain("\u02E6", result); + Assert.DoesNotContain("\u02E7", result); + Assert.DoesNotContain("\u02E8", result); + Assert.DoesNotContain("\u02E9", result); + } + + // ===================================================================== + // 8. バッチ API + // ===================================================================== + + [Fact] + public void ToMisakiIPABatch_複数テキスト_正しい件数を返す() + { + var texts = new[] { "\u4F60\u597D", "\u4E16\u754C", "\u4E2D\u56FD" }; + var results = _engine.ToMisakiIPABatch(texts); + Assert.Equal(3, results.Count); + } + + [Fact] + public void ToMisakiIPABatch_includeTonesがfalse_声調矢印なし() + { + var texts = new[] { "\u5988", "\u9EBB" }; + var results = _engine.ToMisakiIPABatch(texts, false); + foreach (var result in results) + { + Assert.DoesNotContain("\u2192", result); + Assert.DoesNotContain("\u2197", result); + Assert.DoesNotContain("\u2193", result); + Assert.DoesNotContain("\u2198", result); + } + } + + [Fact] + public void ToMisakiIPABatch_空配列_空リストを返す() + { + var results = _engine.ToMisakiIPABatch(Array.Empty()); + Assert.Empty(results); + } + + [Fact] + public void ToMisakiIPABatch_null引数_ArgumentNullExceptionを投げる() + { + Assert.Throws(() => _engine.ToMisakiIPABatch(null!)); + } + + [Fact] + public void ToMisakiIPABatch_includeTones付きnull引数_ArgumentNullExceptionを投げる() + { + Assert.Throws(() => _engine.ToMisakiIPABatch(null!, true)); + } + + [Fact] + public void ToMisakiIPABatch_個別呼び出しと同一結果() + { + var texts = new[] { "\u4F60\u597D", "\u5988", "\u4E8C" }; + var batchResults = _engine.ToMisakiIPABatch(texts); + for (int i = 0; i < texts.Length; i++) + { + var individual = _engine.ToMisakiIPA(texts[i]); + Assert.Equal(individual, batchResults[i]); + } + } + + [Fact] + public void ToMisakiIPABatch_includeTones付き個別呼び出しと同一結果() + { + var texts = new[] { "\u4F60\u597D", "\u5988" }; + var batchResults = _engine.ToMisakiIPABatch(texts, false); + for (int i = 0; i < texts.Length; i++) + { + var individual = _engine.ToMisakiIPA(texts[i], false); + Assert.Equal(individual, batchResults[i]); + } + } + + // ===================================================================== + // 9. Dispose 後の動作 + // ===================================================================== + + [Fact] + public void ToMisakiIPA_Dispose後_ObjectDisposedExceptionを投げる() + { + var engine = new ChineseG2PEngine(); + engine.Dispose(); + Assert.Throws(() => engine.ToMisakiIPA("\u4F60\u597D")); + } + + [Fact] + public void ToMisakiIPA_includeTones付きDispose後_ObjectDisposedExceptionを投げる() + { + var engine = new ChineseG2PEngine(); + engine.Dispose(); + Assert.Throws(() => engine.ToMisakiIPA("\u4F60\u597D", false)); + } + + [Fact] + public void ToMisakiIPABatch_Dispose後_ObjectDisposedExceptionを投げる() + { + var engine = new ChineseG2PEngine(); + engine.Dispose(); + Assert.Throws(() => engine.ToMisakiIPABatch(new[] { "\u4F60\u597D" })); + } + + [Fact] + public void ToMisakiIPABatch_includeTones付きDispose後_ObjectDisposedExceptionを投げる() + { + var engine = new ChineseG2PEngine(); + engine.Dispose(); + Assert.Throws(() => engine.ToMisakiIPABatch(new[] { "\u4F60\u597D" }, true)); + } + + // ===================================================================== + // 10. 複数文字テキスト (音節区切り) + // ===================================================================== + + [Fact] + public void ToMisakiIPA_中国_スペース区切り2音節() + { + var result = _engine.ToMisakiIPA("\u4E2D\u56FD"); + var parts = result.Split(' '); + Assert.Equal(2, parts.Length); + } + + [Fact] + public void ToMisakiIPA_你好世界_4音節3スペース() + { + var result = _engine.ToMisakiIPA("\u4F60\u597D\u4E16\u754C"); + var parts = result.Split(' '); + Assert.Equal(4, parts.Length); + } + + [Fact] + public void ToMisakiIPA_我爱北京天安门_7音節() + { + var result = _engine.ToMisakiIPA("\u6211\u7231\u5317\u4EAC\u5929\u5B89\u95E8"); + var parts = result.Split(' '); + Assert.Equal(7, parts.Length); + Assert.All(parts, p => Assert.NotEmpty(p)); + } + + [Fact] + public void ToMisakiIPA_中国_各音節が声調矢印を含む() + { + var result = _engine.ToMisakiIPA("\u4E2D\u56FD"); + var parts = result.Split(' '); + Assert.Equal(2, parts.Length); + foreach (var part in parts) + { + bool hasToneArrow = part.Contains("\u2192") || part.Contains("\u2197") + || part.Contains("\u2193") || part.Contains("\u2198"); + Assert.True(hasToneArrow, $"音節 '{part}' に声調矢印がありません"); + } + } + + // ===================================================================== + // 11. 標準 IPA / piper-plus との比較 + // ===================================================================== + + [Fact] + public void ToMisakiIPA_と_ToIPA_の出力が異なる() + { + var misakiResult = _engine.ToMisakiIPA("\u5988"); + var standardResult = _engine.ToIPA("\u5988"); + Assert.NotEqual(misakiResult, standardResult); + } + + [Fact] + public void ToMisakiIPA_と_ToPiperIPA_の出力が異なる() + { + var misakiResult = _engine.ToMisakiIPA("\u77E5"); + var piperResult = _engine.ToPiperIPA("\u77E5"); + Assert.NotEqual(misakiResult, piperResult); + } + + [Fact] + public void ToMisakiIPA_声母体系が異なる_zh声母() + { + var misakiResult = _engine.ToMisakiIPA("\u77E5"); + Assert.Contains("\uAB67", misakiResult); // ꭧ (Misaki 合字) + var piperResult = _engine.ToPiperIPA("\u77E5"); + Assert.Contains("t\u0282", piperResult); // tʂ (piper-plus) + Assert.DoesNotContain("\uAB67", piperResult); + } + + [Fact] + public void ToMisakiIPA_と_ToIPA_声調体系の違い() + { + var misakiResult = _engine.ToMisakiIPA("\u5988"); + var standardResult = _engine.ToIPA("\u5988"); + Assert.Contains("\u2192", misakiResult); // → (Misaki 矢印) + Assert.Contains("\u02E5", standardResult); // ˥ (IPA tone letter) + Assert.DoesNotContain("\u02E5", misakiResult); + Assert.DoesNotContain("\u2192", standardResult); + } + + [Fact] + public void ToMisakiIPA_と_ToPiperIPA_声調有無の違い() + { + var misakiResult = _engine.ToMisakiIPA("\u9A6C"); + var piperResult = _engine.ToPiperIPA("\u9A6C"); + Assert.Contains("\u2193", misakiResult); // ↓ (3声) + Assert.DoesNotContain("\u2192", piperResult); + Assert.DoesNotContain("\u2197", piperResult); + Assert.DoesNotContain("\u2193", piperResult); + Assert.DoesNotContain("\u2198", piperResult); + } + + // ===================================================================== + // 12. 回帰確認 + // ===================================================================== + + [Fact] + public void ToMisakiIPA_回帰_ToIPAの妈が既存出力のまま() + { + var result = _engine.ToIPA("\u5988"); + Assert.Equal("ma\u02E5\u02E5", result); + } + + [Fact] + public void ToMisakiIPA_回帰_ToPiperIPAの妈が既存出力のまま() + { + var result = _engine.ToPiperIPA("\u5988"); + Assert.Equal("ma", result); + } + + [Fact] + public void ToMisakiIPA_回帰_ToZhuyinの妈が既存出力のまま() + { + var result = _engine.ToZhuyin("\u5988"); + Assert.Equal("\u3107\u311A", result); + } + + [Fact] + public void ToMisakiIPA_回帰_ToIPA声調false版が不変() + { + var result = _engine.ToIPA("\u5988", false); + Assert.Equal("ma", result); + } + + [Fact] + public void ToMisakiIPA_回帰_ToIPAの你好が既存出力のまま() + { + var result = _engine.ToIPA("\u4F60\u597D"); + Assert.DoesNotContain("\u2192", result); + Assert.DoesNotContain("\u2197", result); + Assert.DoesNotContain("\u2193", result); + Assert.DoesNotContain("\u2198", result); + bool hasToneLetter = result.Contains("\u02E5") || result.Contains("\u02E6") + || result.Contains("\u02E7") || result.Contains("\u02E8") || result.Contains("\u02E9"); + Assert.True(hasToneLetter, "標準IPA出力にtone letterが含まれていません"); + } + } +} From de607ced9c547bc766be01863b182765175898eb Mon Sep 17 00:00:00 2001 From: yousan Date: Mon, 13 Apr 2026 01:46:09 +0900 Subject: [PATCH 15/21] =?UTF-8?q?chore:=20Phase=203=20=E2=80=94=20v1.10.0?= =?UTF-8?q?=20=E3=83=AA=E3=83=AA=E3=83=BC=E3=82=B9=E6=BA=96=E5=82=99=20+?= =?UTF-8?q?=20=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1=E3=83=B3=E3=83=88?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20(Mi3,=20#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T05: ドキュメント更新 - README.md: Misaki互換IPA のコードサンプル・特徴説明・APIテーブル追加 - CLAUDE.md: 中国語テスト数 1776+、Misaki互換IPA対応を進捗テーブルに反映 - docs/guides/misaki-milestones.md: Mi1-Mi3 全マイルストーン完了 - docs/guides/misaki-compatible-chinese.md: API名統一(ToMisakiIPA)、Phase 1-R知見追記 T06: v1.10.0 リリース準備 - Directory.Build.props + 全10 package.json のバージョンを 1.9.0 → 1.10.0 に更新 - 破壊的変更なし(既存API のシグネチャ・挙動は v1.9.x と同一) --- CLAUDE.md | 4 ++-- Directory.Build.props | 4 ++-- README.md | 19 ++++++++++++++++++- docs/guides/misaki-compatible-chinese.md | 17 +++++++++++------ docs/guides/misaki-milestones.md | 6 +++--- src/DotNetG2P.Chinese/package.json | 2 +- src/DotNetG2P.Core/package.json | 2 +- src/DotNetG2P.English/package.json | 2 +- src/DotNetG2P.French/package.json | 2 +- src/DotNetG2P.Korean/package.json | 2 +- src/DotNetG2P.MeCab/package.json | 4 ++-- src/DotNetG2P.Multilingual/package.json | 20 ++++++++++---------- src/DotNetG2P.Portuguese/package.json | 2 +- src/DotNetG2P.Spanish/package.json | 2 +- src/DotNetG2P.Swedish/package.json | 2 +- 15 files changed, 56 insertions(+), 34 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ad2d5a2..7f0cc54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,13 +9,13 @@ OpenJTalk互換の日本語G2Pパイプライン、CMU辞書ベースの英語G2 ## 進捗状況 -全マイルストーン完了済み。現在 v1.9.0。 +全マイルストーン完了済み。現在 v1.10.0。 | 言語 | パッケージ | 状態 | テスト数 | 備考 | |------|-----------|------|---------|------| | 日本語 | DotNetG2P.Core + MeCab | M1-M7完了 | 950+ | OpenJTalk互換パイプライン、独自MeCabエンジン、パフォーマンス最適化済み | | 英語 | DotNetG2P.English | E1-E7完了 | 511 | CMU辞書135k語 + Flite LTS CARTツリー(PER 5.26%)、同綴異音語解決、piper-plus互換IPA | -| 中国語 | DotNetG2P.Chinese | C1-C6完了 | 936 | pinyin-data 44k + phrase-pinyin-data 412kエントリ、声調変調、IPA/注音/piper-plus互換 | +| 中国語 | DotNetG2P.Chinese | C1-C6完了 | 1776+ | pinyin-data 44k + phrase-pinyin-data 412kエントリ、声調変調、IPA/注音/piper-plus互換、Misaki互換IPA出力(Kokoro TTS向け) | | 韓国語 | DotNetG2P.Korean | K1-K4完了 | 375 | Hangul-first規則ベース、IPA/PUA/Prosody API | | スペイン語 | DotNetG2P.Spanish | S1-S4完了 | 355 | ipa-dict PER 1.69%(base)/1.37%(allophones)、LatinAmerican/Castilian方言 | | フランス語 | DotNetG2P.French | F1-F4完了 | 719 | 例外辞書500+語、Metropolitan/Conservative方言 | diff --git a/Directory.Build.props b/Directory.Build.props index 32c8015..3e63452 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,8 +10,8 @@ $(MSBuildThisFileDirectory).build\bin\$(MSBuildProjectName)\ false - 1.9.0 - 1.9.0-local + 1.10.0 + 1.10.0-local $(Version) true true diff --git a/README.md b/README.md index ff24816..a82060d 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ enEngine.ToPhonemes("hello world"); // => "HH AH0 L OW1 W ER1 L D" // 中国語G2P(ピンイン変換) using var zhEngine = new ChineseG2PEngine(); zhEngine.ToPinyin("你好世界"); // => "ní hǎo shì jiè" +zhEngine.ToMisakiIPA("你好世界"); // => Misaki互換IPA(Kokoro TTS向け) // 韓国語G2P using var koEngine = new KoreanG2PEngine(); @@ -75,7 +76,7 @@ multiEngine.ToPhonemes("今日は안녕하세요 hello"); // 日本語部分は - **Unity対応** — .NET Standard 2.1(Unity 2021.2+)ターゲット、UPMパッケージ提供 - **拡張可能な設計** — `ITokenizer`インターフェースにより形態素解析エンジンを差し替え可能 - **英語G2P対応** — CMU辞書(135,000語)+ Flite LTSルールによるOOV推定、IPA/X-SAMPA出力、テキスト正規化、同綴異音語解決、piper-plus 互換 IPA/PUA/Prosody API -- **中国語G2P対応** — pinyin-data単字辞書(44,000語)+ phrase-pinyin-dataフレーズ辞書(411,000語)による多音字自動解決、声調変調(三声連読・一/不変調)、3種の出力スタイル、IPA(国際音声記号)・注音符号(ボポモフォ)出力、piper-plus 互換 IPA/PUA/Prosody API +- **中国語G2P対応** — pinyin-data単字辞書(44,000語)+ phrase-pinyin-dataフレーズ辞書(411,000語)による多音字自動解決、声調変調(三声連読・一/不変調)、4種の出力スタイル、IPA(国際音声記号)・注音符号(ボポモフォ)出力、Misaki互換IPA出力(Kokoro TTS向け)、piper-plus 互換 IPA/PUA/Prosody API - **韓国語G2P対応** — Hangul-first の規則ベース変換、Jamo 分解、例外辞書、軽量正規化、`ㅎ` 系変化・終声中和・連音・濃音化・鼻音化・流音化を含む標準発音寄り rule engine、piper-plus 互換 IPA/PUA/Prosody API、benchmark harness、external corpus gate、performance test を実装 - **スペイン語G2P対応** — ルールベースIPA変換、音節分割、ストレス付与、Castilian/Latin American 切り替え、異音処理オプション、略語/数値/通貨/割合の正規化、例外辞書、全量コーパス評価ツールを実装。桁区切り/小数点の解釈分離と不正な日付/時刻の安全なフォールバックにも対応。PUA/Prosody API - **フランス語G2P対応** — ルールベース6フェーズG2P変換(ダイグラフ→文脈依存→鼻母音化→半母音化→位置の法則→黙字)、音素ベース音節分割、Metropolitan/Conservative方言切り替え、異音処理(R無声化・阻害音有声性同化)、例外辞書500+エントリ(外来語/不規則語/動詞3複/学術語/同綴異音語)、テキスト正規化(数値/日付/時刻/通貨/単位/略語/記号)、IPA/X-SAMPA出力、全量コーパス評価ツール、PUA/Prosody API を実装 @@ -239,6 +240,18 @@ string[] zhPua = zhEngine.ToPuaPhonemes("你好世界"); // Prosody 情報 var zhResult = zhEngine.ToIpaWithProsody("你好世界"); +// Misaki 互換 IPA(Kokoro TTS向け) +string misakiIpa = zhEngine.ToMisakiIPA("你好"); +// => "ni↗ xau↓"(三声連読適用: 你3+好3 → 你2+好3) +// 声調記号: → (1声), ↗ (2声), ↓ (3声), ↘ (4声) + +// 声調なし Misaki 互換 IPA +string misakiNoTone = zhEngine.ToMisakiIPA("你好", includeTones: false); +// => "ni xau" + +// バッチ変換 +var misakiBatch = zhEngine.ToMisakiIPABatch(new[] { "你好", "世界" }); + // === 英語G2P === using DotNetG2P.English; @@ -510,6 +523,10 @@ using var multiUnityEngine = new MultilingualG2PEngine(japaneseDicPath, unityOpt | `ToPuaStringBatch(texts)` | `IReadOnlyList` | バッチ PUA 文字列変換 | | `ToIpaWithProsodyBatch(texts)` | `IReadOnlyList` | バッチ IPA+Prosody 変換 | | `ToIpaWithProsodyBatch(texts, includeTones)` | `IReadOnlyList` | バッチ IPA+Prosody 変換(声調制御) | +| `ToMisakiIPA(text)` | `string` | Misaki互換IPA文字列(矢印声調記号付き) | +| `ToMisakiIPA(text, includeTones)` | `string` | 声調制御付きMisaki互換IPA | +| `ToMisakiIPABatch(texts)` | `IReadOnlyList` | バッチMisaki互換IPA変換 | +| `ToMisakiIPABatch(texts, includeTones)` | `IReadOnlyList` | バッチMisaki互換IPA変換(声調制御) | ### KoreanG2PEngine diff --git a/docs/guides/misaki-compatible-chinese.md b/docs/guides/misaki-compatible-chinese.md index c842da7..000edb3 100644 --- a/docs/guides/misaki-compatible-chinese.md +++ b/docs/guides/misaki-compatible-chinese.md @@ -59,7 +59,7 @@ DotNetG2P.ChineseにMisaki互換出力モードを追加することで、C#/Uni | 方式 | 概要 | 判定 | |------|------|------| | A: PinyinStyle に追加 | PinyinStyle はピンイン表記用 enum。IPA 出力とはレイヤーが異なる | **不採用** (責務混在) | -| **B: PinyinToMisaki.cs 新規 + ToMisakiIpa()** | PiperIpa と同パターン。独立マッピングテーブル | **採用** | +| **B: PinyinToMisaki.cs 新規 + ToMisakiIPA()** | PiperIpa と同パターン。独立マッピングテーブル | **採用** | | C: ToIPA() 出力のポストプロセス | 文字列置換で変換。脆弱で将来変更に弱い | **不採用** | ### 採用: 方式B — 独立変換クラス + 専用メソッド @@ -94,7 +94,7 @@ src/DotNetG2P.Chinese/Conversion/ | ファイル | 内容 | |---------|------| -| `src/DotNetG2P.Chinese/ChineseG2PEngine.cs` | `ToMisakiIpa()` / `ToMisakiIpaBatch()` 公開メソッド追加 | +| `src/DotNetG2P.Chinese/ChineseG2PEngine.cs` | `ToMisakiIPA()` / `ToMisakiIPABatch()` 公開メソッド追加 | ### 変更不要 (共通基盤) @@ -170,12 +170,12 @@ private static readonly string[] s_toneArrows = new[] ```csharp // 文字列出力 -public string ToMisakiIpa(string text) -public string ToMisakiIpa(string text, bool includeTones) +public string ToMisakiIPA(string text) +public string ToMisakiIPA(string text, bool includeTones) // バッチ出力 -public string[] ToMisakiIpaBatch(string[] texts) -public string[] ToMisakiIpaBatch(string[] texts, bool includeTones) +public string[] ToMisakiIPABatch(string[] texts) +public string[] ToMisakiIPABatch(string[] texts, bool includeTones) ``` ## テスト方針 @@ -194,3 +194,8 @@ public string[] ToMisakiIpaBatch(string[] texts, bool includeTones) - Misaki には Legacy パス (IPA+矢印) と v1.1 パス (注音符号) の2つが存在するが、Kokoro-82M で使用されるのは Legacy パスのみ。本対応は Legacy パスを対象とする - Misaki が `ꭧ` (U+AB67) を zh/ch の子音IPAに使用する件は、Kokoro vocab に含まれない可能性があるため初期対応では見送り、必要に応じて追加する - Multilingual 層への統合は将来の追加作業とする + +### Phase 1-R 実装知見 + +- Misaki legacy は 3-3 tone sandhi (三声連読変調) を適用しない。DotNetG2P では `EnableToneSandhi` オプションで制御可能。Misaki legacy と完全一致させるには `EnableToneSandhi = false` でエンジンを初期化する +- U+032F (COMBINING INVERTED BREVE BELOW, 非音節化符号) は Misaki テンプレート側で事前除去されるため、実際の出力には含まれない。DotNetG2P の `ToMisakiIPA()` はマッピングテーブルに U+032F を含むが、Kokoro TTS に渡す前にテンプレート処理で除去される想定 diff --git a/docs/guides/misaki-milestones.md b/docs/guides/misaki-milestones.md index 68a1ea9..b17d704 100644 --- a/docs/guides/misaki-milestones.md +++ b/docs/guides/misaki-milestones.md @@ -145,6 +145,6 @@ DotNetG2P.Chinese に Misaki 互換出力モードを追加する。全3マイ | マイルストーン | 内容 | 状態 | |--------------|------|------| -| **Mi1** | PinyinToMisaki 変換クラス | 未着手 | -| **Mi2** | API統合 + テスト | 未着手 | -| **Mi3** | ドキュメント・品質保証・リリース準備 | 未着手 | +| **Mi1** | PinyinToMisaki 変換クラス | 完了 | +| **Mi2** | API統合 + テスト | 完了 | +| **Mi3** | ドキュメント・品質保証・リリース準備 | 完了 | diff --git a/src/DotNetG2P.Chinese/package.json b/src/DotNetG2P.Chinese/package.json index ef31616..e17f30e 100644 --- a/src/DotNetG2P.Chinese/package.json +++ b/src/DotNetG2P.Chinese/package.json @@ -1,7 +1,7 @@ { "name": "com.dotnetg2p.chinese", "displayName": "DotNetG2P Chinese", - "version": "1.9.0", + "version": "1.10.0", "unity": "2021.2", "description": "Chinese Grapheme-to-Phoneme (G2P) library for .NET and Unity. Pinyin conversion with tone support, polyphone disambiguation, tone sandhi (third-tone and yi/bu rules), and multiple output formats (numbered, diacritical, Zhuyin).", "keywords": [ diff --git a/src/DotNetG2P.Core/package.json b/src/DotNetG2P.Core/package.json index 1ee7668..20029a6 100644 --- a/src/DotNetG2P.Core/package.json +++ b/src/DotNetG2P.Core/package.json @@ -1,7 +1,7 @@ { "name": "com.dotnetg2p.core", "displayName": "DotNetG2P", - "version": "1.9.0", + "version": "1.10.0", "unity": "2021.2", "description": "Japanese Grapheme-to-Phoneme (G2P) library for Unity. OpenJTalk-compatible rule-based G2P pipeline with NJD processing, phoneme conversion, and HTS full-context label generation.", "keywords": [ diff --git a/src/DotNetG2P.English/package.json b/src/DotNetG2P.English/package.json index d12af0d..362e658 100644 --- a/src/DotNetG2P.English/package.json +++ b/src/DotNetG2P.English/package.json @@ -1,7 +1,7 @@ { "name": "com.dotnetg2p.english", "displayName": "DotNetG2P.English", - "version": "1.9.0", + "version": "1.10.0", "unity": "2021.2", "description": "English Grapheme-to-Phoneme (G2P) library for .NET and Unity. CMU Dictionary lookup (135K words), Flite LTS for OOV estimation, text normalization, homograph resolution, and IPA/X-SAMPA output.", "keywords": [ diff --git a/src/DotNetG2P.French/package.json b/src/DotNetG2P.French/package.json index f86ea6c..575196f 100644 --- a/src/DotNetG2P.French/package.json +++ b/src/DotNetG2P.French/package.json @@ -1,7 +1,7 @@ { "name": "com.dotnetg2p.french", "displayName": "DotNetG2P.French", - "version": "1.9.0", + "version": "1.10.0", "unity": "2021.2", "description": "French Grapheme-to-Phoneme (G2P) library for .NET and Unity. Rule-based IPA conversion with syllabification, nasal vowels, and Metropolitan/Conservative dialect options.", "keywords": [ diff --git a/src/DotNetG2P.Korean/package.json b/src/DotNetG2P.Korean/package.json index ecf892a..28142af 100644 --- a/src/DotNetG2P.Korean/package.json +++ b/src/DotNetG2P.Korean/package.json @@ -1,7 +1,7 @@ { "name": "com.dotnetg2p.korean", "displayName": "DotNetG2P.Korean", - "version": "1.9.0", + "version": "1.10.0", "unity": "2021.2", "description": "Korean Grapheme-to-Phoneme (G2P) library for .NET and Unity. Hangul-first rule-based conversion with standard pronunciation rules, exception dictionary support, lightweight normalization, and Jamo decomposition.", "keywords": [ diff --git a/src/DotNetG2P.MeCab/package.json b/src/DotNetG2P.MeCab/package.json index 0fb9ace..362738b 100644 --- a/src/DotNetG2P.MeCab/package.json +++ b/src/DotNetG2P.MeCab/package.json @@ -1,7 +1,7 @@ { "name": "com.dotnetg2p.mecab", "displayName": "DotNetG2P.MeCab", - "version": "1.9.0", + "version": "1.10.0", "unity": "2021.2", "description": "Pure C# MeCab-compatible morphological analyzer for DotNetG2P. No external native dependencies. Apache-2.0 licensed.", "keywords": [ @@ -13,7 +13,7 @@ ], "license": "Apache-2.0", "dependencies": { - "com.dotnetg2p.core": "1.9.0" + "com.dotnetg2p.core": "1.10.0" }, "author": { "name": "ayutaz" diff --git a/src/DotNetG2P.Multilingual/package.json b/src/DotNetG2P.Multilingual/package.json index 3841522..d2bc830 100644 --- a/src/DotNetG2P.Multilingual/package.json +++ b/src/DotNetG2P.Multilingual/package.json @@ -1,7 +1,7 @@ { "name": "com.dotnetg2p.multilingual", "displayName": "DotNetG2P.Multilingual", - "version": "1.9.0", + "version": "1.10.0", "unity": "2021.2", "description": "Multilingual G2P engine combining Japanese, English, Chinese, Korean, Spanish, French, Portuguese, and Swedish grapheme-to-phoneme support with automatic language detection and segment routing.", "keywords": [ @@ -23,15 +23,15 @@ ], "license": "Apache-2.0", "dependencies": { - "com.dotnetg2p.core": "1.9.0", - "com.dotnetg2p.mecab": "1.9.0", - "com.dotnetg2p.english": "1.9.0", - "com.dotnetg2p.chinese": "1.9.0", - "com.dotnetg2p.korean": "1.9.0", - "com.dotnetg2p.spanish": "1.9.0", - "com.dotnetg2p.french": "1.9.0", - "com.dotnetg2p.portuguese": "1.9.0", - "com.dotnetg2p.swedish": "1.9.0" + "com.dotnetg2p.core": "1.10.0", + "com.dotnetg2p.mecab": "1.10.0", + "com.dotnetg2p.english": "1.10.0", + "com.dotnetg2p.chinese": "1.10.0", + "com.dotnetg2p.korean": "1.10.0", + "com.dotnetg2p.spanish": "1.10.0", + "com.dotnetg2p.french": "1.10.0", + "com.dotnetg2p.portuguese": "1.10.0", + "com.dotnetg2p.swedish": "1.10.0" }, "author": { "name": "ayutaz" diff --git a/src/DotNetG2P.Portuguese/package.json b/src/DotNetG2P.Portuguese/package.json index 793694c..480f424 100644 --- a/src/DotNetG2P.Portuguese/package.json +++ b/src/DotNetG2P.Portuguese/package.json @@ -1,7 +1,7 @@ { "name": "com.dotnetg2p.portuguese", "displayName": "DotNetG2P.Portuguese", - "version": "1.9.0", + "version": "1.10.0", "unity": "2021.2", "description": "Portuguese G2P library for .NET and Unity. Rule-based IPA conversion with syllabification, stress assignment, nasal vowels, and Brazilian/European dialect support.", "keywords": ["g2p", "portuguese", "tts", "phoneme", "ipa"], diff --git a/src/DotNetG2P.Spanish/package.json b/src/DotNetG2P.Spanish/package.json index 95f7ec9..6bcdc7b 100644 --- a/src/DotNetG2P.Spanish/package.json +++ b/src/DotNetG2P.Spanish/package.json @@ -1,7 +1,7 @@ { "name": "com.dotnetg2p.spanish", "displayName": "DotNetG2P.Spanish", - "version": "1.9.0", + "version": "1.10.0", "unity": "2021.2", "description": "Spanish Grapheme-to-Phoneme (G2P) library for .NET and Unity. Rule-based IPA conversion with syllabification, stress assignment, and Castilian/Latin American dialect options.", "keywords": [ diff --git a/src/DotNetG2P.Swedish/package.json b/src/DotNetG2P.Swedish/package.json index a6d86f9..6fcb058 100644 --- a/src/DotNetG2P.Swedish/package.json +++ b/src/DotNetG2P.Swedish/package.json @@ -1,7 +1,7 @@ { "name": "com.dotnetg2p.swedish", "displayName": "DotNetG2P.Swedish", - "version": "1.9.0", + "version": "1.10.0", "unity": "2021.2", "description": "Swedish Grapheme-to-Phoneme (G2P) library for .NET and Unity. Rule-based IPA conversion with syllabification, stress assignment, pitch accent prediction, and Central/Finland Swedish dialect options.", "keywords": [ From f4dd75ed4e3142841125de2190cfa6ce393c4439 Mon Sep 17 00:00:00 2001 From: yousan Date: Mon, 13 Apr 2026 08:45:45 +0900 Subject: [PATCH 16/21] =?UTF-8?q?docs:=20M3=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E6=8C=87=E6=91=98=E4=BF=AE=E6=AD=A3=20=E2=80=94=20?= =?UTF-8?q?=E3=83=81=E3=82=B1=E3=83=83=E3=83=88=20status=E3=83=BBAPI?= =?UTF-8?q?=E5=90=8D=E7=B5=B1=E4=B8=80=E3=83=BBCHANGELOG=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー指摘による修正: - T01-T06 全チケットの status を「未着手」→「完了」に更新 - T03-T05 チケット内の API名を ToMisakiIpa → ToMisakiIPA に統一 (213箇所) - CHANGELOG.md に v1.10.0 エントリを追加 (Misaki互換IPA API) - README.md の「4種の出力スタイル」→「多彩な出力スタイル」に修正 - CLAUDE.md の進捗表記を「C1-C6 + Mi1-Mi3完了」に更新 --- CHANGELOG.md | 19 +- CLAUDE.md | 2 +- README.md | 2 +- docs/tickets/README.md | 12 +- docs/tickets/T01-misaki-mapping-tables.md | 2 +- docs/tickets/T02-misaki-convert-method.md | 2 +- docs/tickets/T03-engine-api-integration.md | 144 ++++++------- docs/tickets/T04-misaki-tests.md | 240 ++++++++++----------- docs/tickets/T05-documentation-qa.md | 48 ++--- docs/tickets/T06-release-followup.md | 2 +- 10 files changed, 245 insertions(+), 228 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49a8764..8e543a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.10.0] - 2026-04-13 + +### Added +- **DotNetG2P.Chinese**: Misaki (Kokoro TTS) 互換 IPA 出力 API を追加 (#56) + - `ChineseG2PEngine.ToMisakiIPA(text)` — Misaki 互換 IPA 文字列(矢印声調記号付き) + - `ChineseG2PEngine.ToMisakiIPA(text, includeTones)` — 声調制御付き + - `ChineseG2PEngine.ToMisakiIPABatch(texts)` / `ToMisakiIPABatch(texts, includeTones)` — バッチ変換 + - `PinyinToMisaki` 内部変換クラス — Misaki 0.9.4 実測ゴールドデータ (137件) で検証済み + - 統合テスト 130 件追加 + +### Changed +- (なし) + +### Fixed +- (なし) + ## [1.9.0] - 2026-04-02 ### Added @@ -303,7 +319,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - LibNMeCab依存を削除 -[Unreleased]: https://github.com/ayutaz/dot-net-g2p/compare/v1.9.0...HEAD +[Unreleased]: https://github.com/ayutaz/dot-net-g2p/compare/v1.10.0...HEAD +[1.10.0]: https://github.com/ayutaz/dot-net-g2p/compare/v1.9.0...v1.10.0 [1.9.0]: https://github.com/ayutaz/dot-net-g2p/compare/v1.8.2...v1.9.0 [1.8.2]: https://github.com/ayutaz/dot-net-g2p/compare/v1.8.1...v1.8.2 [1.8.1]: https://github.com/ayutaz/dot-net-g2p/compare/v1.8.0...v1.8.1 diff --git a/CLAUDE.md b/CLAUDE.md index 7f0cc54..7932e65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ OpenJTalk互換の日本語G2Pパイプライン、CMU辞書ベースの英語G2 |------|-----------|------|---------|------| | 日本語 | DotNetG2P.Core + MeCab | M1-M7完了 | 950+ | OpenJTalk互換パイプライン、独自MeCabエンジン、パフォーマンス最適化済み | | 英語 | DotNetG2P.English | E1-E7完了 | 511 | CMU辞書135k語 + Flite LTS CARTツリー(PER 5.26%)、同綴異音語解決、piper-plus互換IPA | -| 中国語 | DotNetG2P.Chinese | C1-C6完了 | 1776+ | pinyin-data 44k + phrase-pinyin-data 412kエントリ、声調変調、IPA/注音/piper-plus互換、Misaki互換IPA出力(Kokoro TTS向け) | +| 中国語 | DotNetG2P.Chinese | C1-C6 + Mi1-Mi3完了 | 1776+ | pinyin-data 44k + phrase-pinyin-data 412kエントリ、声調変調、IPA/注音/piper-plus互換、Misaki互換IPA出力(Kokoro TTS向け) | | 韓国語 | DotNetG2P.Korean | K1-K4完了 | 375 | Hangul-first規則ベース、IPA/PUA/Prosody API | | スペイン語 | DotNetG2P.Spanish | S1-S4完了 | 355 | ipa-dict PER 1.69%(base)/1.37%(allophones)、LatinAmerican/Castilian方言 | | フランス語 | DotNetG2P.French | F1-F4完了 | 719 | 例外辞書500+語、Metropolitan/Conservative方言 | diff --git a/README.md b/README.md index a82060d..54ad4ff 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ multiEngine.ToPhonemes("今日は안녕하세요 hello"); // 日本語部分は - **Unity対応** — .NET Standard 2.1(Unity 2021.2+)ターゲット、UPMパッケージ提供 - **拡張可能な設計** — `ITokenizer`インターフェースにより形態素解析エンジンを差し替え可能 - **英語G2P対応** — CMU辞書(135,000語)+ Flite LTSルールによるOOV推定、IPA/X-SAMPA出力、テキスト正規化、同綴異音語解決、piper-plus 互換 IPA/PUA/Prosody API -- **中国語G2P対応** — pinyin-data単字辞書(44,000語)+ phrase-pinyin-dataフレーズ辞書(411,000語)による多音字自動解決、声調変調(三声連読・一/不変調)、4種の出力スタイル、IPA(国際音声記号)・注音符号(ボポモフォ)出力、Misaki互換IPA出力(Kokoro TTS向け)、piper-plus 互換 IPA/PUA/Prosody API +- **中国語G2P対応** — pinyin-data単字辞書(44,000語)+ phrase-pinyin-dataフレーズ辞書(411,000語)による多音字自動解決、声調変調(三声連読・一/不変調)、多彩な出力スタイル、IPA(国際音声記号)・注音符号(ボポモフォ)出力、Misaki互換IPA出力(Kokoro TTS向け)、piper-plus 互換 IPA/PUA/Prosody API - **韓国語G2P対応** — Hangul-first の規則ベース変換、Jamo 分解、例外辞書、軽量正規化、`ㅎ` 系変化・終声中和・連音・濃音化・鼻音化・流音化を含む標準発音寄り rule engine、piper-plus 互換 IPA/PUA/Prosody API、benchmark harness、external corpus gate、performance test を実装 - **スペイン語G2P対応** — ルールベースIPA変換、音節分割、ストレス付与、Castilian/Latin American 切り替え、異音処理オプション、略語/数値/通貨/割合の正規化、例外辞書、全量コーパス評価ツールを実装。桁区切り/小数点の解釈分離と不正な日付/時刻の安全なフォールバックにも対応。PUA/Prosody API - **フランス語G2P対応** — ルールベース6フェーズG2P変換(ダイグラフ→文脈依存→鼻母音化→半母音化→位置の法則→黙字)、音素ベース音節分割、Metropolitan/Conservative方言切り替え、異音処理(R無声化・阻害音有声性同化)、例外辞書500+エントリ(外来語/不規則語/動詞3複/学術語/同綴異音語)、テキスト正規化(数値/日付/時刻/通貨/単位/略語/記号)、IPA/X-SAMPA出力、全量コーパス評価ツール、PUA/Prosody API を実装 diff --git a/docs/tickets/README.md b/docs/tickets/README.md index 1bbcc9f..204a8ea 100644 --- a/docs/tickets/README.md +++ b/docs/tickets/README.md @@ -10,22 +10,22 @@ | ID | タイトル | 状態 | 依存 | 後続 | |----|----------|------|------|------| -| [T01](T01-misaki-mapping-tables.md) | PinyinToMisaki マッピングテーブル設計・実装 | 未着手 | なし | T02 | -| [T02](T02-misaki-convert-method.md) | PinyinToMisaki Convert メソッド統合 | 未着手 | T01 | T03 | +| [T01](T01-misaki-mapping-tables.md) | PinyinToMisaki マッピングテーブル設計・実装 | 完了 | なし | T02 | +| [T02](T02-misaki-convert-method.md) | PinyinToMisaki Convert メソッド統合 | 完了 | T01 | T03 | ### Phase 2 — Mi2: API統合 + テスト | ID | タイトル | 状態 | 依存 | 後続 | |----|----------|------|------|------| -| [T03](T03-engine-api-integration.md) | ChineseG2PEngine ToMisakiIpa API 追加 | 未着手 | T02 | T04 | -| [T04](T04-misaki-tests.md) | Misaki互換テスト実装 | 未着手 | T03 | T05 | +| [T03](T03-engine-api-integration.md) | ChineseG2PEngine ToMisakiIPA API 追加 | 完了 | T02 | T04 | +| [T04](T04-misaki-tests.md) | Misaki互換テスト実装 | 完了 | T03 | T05 | ### Phase 3 — Mi3: ドキュメント・品質保証・リリース準備 | ID | タイトル | 状態 | 依存 | 後続 | |----|----------|------|------|------| -| [T05](T05-documentation-qa.md) | ドキュメント更新・品質保証 | 未着手 | T04 | T06 | -| [T06](T06-release-followup.md) | Issue#56 フォローアップ・リリース準備 | 未着手 | T05 | なし | +| [T05](T05-documentation-qa.md) | ドキュメント更新・品質保証 | 完了 | T04 | T06 | +| [T06](T06-release-followup.md) | Issue#56 フォローアップ・リリース準備 | 完了 | T05 | なし | ## 依存関係グラフ diff --git a/docs/tickets/T01-misaki-mapping-tables.md b/docs/tickets/T01-misaki-mapping-tables.md index c8f3a3e..2e9b4ac 100644 --- a/docs/tickets/T01-misaki-mapping-tables.md +++ b/docs/tickets/T01-misaki-mapping-tables.md @@ -2,7 +2,7 @@ ticket: T01 title: PinyinToMisaki マッピングテーブル設計・実装 milestone: Mi1 -status: 未着手 +status: 完了 depends_on: [] blocks: [T02] --- diff --git a/docs/tickets/T02-misaki-convert-method.md b/docs/tickets/T02-misaki-convert-method.md index 879272e..f7c5ff0 100644 --- a/docs/tickets/T02-misaki-convert-method.md +++ b/docs/tickets/T02-misaki-convert-method.md @@ -2,7 +2,7 @@ ticket: T02 title: PinyinToMisaki Convert メソッド統合 (Phase 1-R verified) milestone: Mi1 -status: 未着手 +status: 完了 depends_on: [T01] blocks: [T03] --- diff --git a/docs/tickets/T03-engine-api-integration.md b/docs/tickets/T03-engine-api-integration.md index 6b70533..f56d92d 100644 --- a/docs/tickets/T03-engine-api-integration.md +++ b/docs/tickets/T03-engine-api-integration.md @@ -1,13 +1,13 @@ --- ticket: T03 -title: ChineseG2PEngine ToMisakiIpa API 追加 +title: ChineseG2PEngine ToMisakiIPA API 追加 milestone: Mi2 -status: 未着手 +status: 完了 depends_on: [T02] blocks: [T04] --- -# T03: ChineseG2PEngine ToMisakiIpa API 追加 +# T03: ChineseG2PEngine ToMisakiIPA API 追加 ## 1. タスク目的とゴール @@ -15,13 +15,13 @@ ChineseG2PEngine に Misaki 互換 IPA 出力 API を追加し、ユーザーが ```csharp var engine = new ChineseG2PEngine(); -string ipa = engine.ToMisakiIpa("你好"); +string ipa = engine.ToMisakiIPA("你好"); // => Misaki互換のIPA文字列が返る -string ipaNoTones = engine.ToMisakiIpa("你好", includeTones: false); +string ipaNoTones = engine.ToMisakiIPA("你好", includeTones: false); // => 声調なしMisaki互換IPA -var batch = engine.ToMisakiIpaBatch(new[] { "你好", "世界" }); +var batch = engine.ToMisakiIPABatch(new[] { "你好", "世界" }); // => 複数テキストの一括変換 ``` @@ -115,7 +115,7 @@ private static string ConvertIpaBatchItem(ChineseG2PEngine engine, string text, 以下の4メソッドを `ChineseG2PEngine.cs` に追加する。配置場所は piper-plus 互換 IPA セクションの直後(PUA出力セクションの前)に新しいセクションコメントとともに挿入する。 -#### 2.2.1 ToMisakiIpa(string text) — 声調付きデフォルト +#### 2.2.1 ToMisakiIPA(string text) — 声調付きデフォルト ```csharp // ===================================================================== @@ -127,13 +127,13 @@ private static string ConvertIpaBatchItem(ChineseG2PEngine engine, string text, /// /// 入力テキスト /// Misaki 互換 IPA 文字列 -public string ToMisakiIpa(string text) +public string ToMisakiIPA(string text) { - return ToMisakiIpa(text, true); + return ToMisakiIPA(text, true); } ``` -#### 2.2.2 ToMisakiIpa(string text, bool includeTones) — 声調制御付き +#### 2.2.2 ToMisakiIPA(string text, bool includeTones) — 声調制御付き ```csharp /// @@ -142,7 +142,7 @@ public string ToMisakiIpa(string text) /// 入力テキスト /// 声調マーカーを含めるかどうか /// Misaki 互換 IPA 文字列 -public string ToMisakiIpa(string text, bool includeTones) +public string ToMisakiIPA(string text, bool includeTones) { return RunPipeline(text, p => PinyinToMisaki.Convert(p, includeTones)); } @@ -150,7 +150,7 @@ public string ToMisakiIpa(string text, bool includeTones) **ポイント**: `RunPipeline` にラムダを渡すパターンは `ToIPA(text, includeTones)` と完全に同一。`PinyinToMisaki.Convert` は T01-T02 で実装済みの `PinyinToMisaki` 静的クラスの変換メソッドで、シグネチャは `public static string Convert(string pinyin, bool includeTones)` を想定する。 -#### 2.2.3 ToMisakiIpaBatch(string[] texts) — バッチ(声調付きデフォルト) +#### 2.2.3 ToMisakiIPABatch(string[] texts) — バッチ(声調付きデフォルト) ```csharp /// @@ -158,16 +158,16 @@ public string ToMisakiIpa(string text, bool includeTones) /// /// 入力テキストの配列 /// 各テキストに対応する Misaki 互換 IPA 文字列のリスト -public IReadOnlyList ToMisakiIpaBatch(string[] texts) +public IReadOnlyList ToMisakiIPABatch(string[] texts) { ThrowIfDisposed(); - return BatchConversionHelper.ConvertToList(texts, ToMisakiIpa); + return BatchConversionHelper.ConvertToList(texts, ToMisakiIPA); } ``` -**ポイント**: `ToPiperIPABatch` と同一パターン。`ThrowIfDisposed()` の後、`BatchConversionHelper.ConvertToList` にメソッドグループ `ToMisakiIpa`(引数1つのオーバーロード)を渡す。`ConvertToList(IReadOnlyList, Func)` オーバーロードが使われる。 +**ポイント**: `ToPiperIPABatch` と同一パターン。`ThrowIfDisposed()` の後、`BatchConversionHelper.ConvertToList` にメソッドグループ `ToMisakiIPA`(引数1つのオーバーロード)を渡す。`ConvertToList(IReadOnlyList, Func)` オーバーロードが使われる。 -#### 2.2.4 ToMisakiIpaBatch(string[] texts, bool includeTones) — バッチ(声調制御付き) +#### 2.2.4 ToMisakiIPABatch(string[] texts, bool includeTones) — バッチ(声調制御付き) ```csharp /// @@ -176,7 +176,7 @@ public IReadOnlyList ToMisakiIpaBatch(string[] texts) /// 入力テキストの配列 /// 声調マーカーを含めるかどうか /// 各テキストに対応する Misaki 互換 IPA 文字列のリスト -public IReadOnlyList ToMisakiIpaBatch(string[] texts, bool includeTones) +public IReadOnlyList ToMisakiIPABatch(string[] texts, bool includeTones) { ThrowIfDisposed(); return BatchConversionHelper.ConvertToList( @@ -194,7 +194,7 @@ public IReadOnlyList ToMisakiIpaBatch(string[] texts, bool includeTones) ```csharp private static string ConvertMisakiIpaBatchItem(ChineseG2PEngine engine, string text, bool includeTones) { - return engine.ToMisakiIpa(text, includeTones); + return engine.ToMisakiIPA(text, includeTones); } ``` @@ -244,8 +244,8 @@ using DotNetG2P.Chinese.Conversion; // ← 既存。PinyinToMisaki もここに // ===================================================================== // Misaki 互換 IPA 出力 ← ★ここに新セクションを挿入 // ===================================================================== - ToMisakiIpa(string text) - ToMisakiIpa(string text, bool includeTones) + ToMisakiIPA(string text) + ToMisakiIPA(string text, bool includeTones) // ===================================================================== // PUA 出力 (既存: L295-L350) @@ -254,7 +254,7 @@ using DotNetG2P.Chinese.Conversion; // ← 既存。PinyinToMisaki もここに ToPuaString(string text) ``` -バッチ API セクション内では、`ToPiperIPABatch` の直後に `ToMisakiIpaBatch` 2メソッドを追加する。 +バッチ API セクション内では、`ToPiperIPABatch` の直後に `ToMisakiIPABatch` 2メソッドを追加する。 静的バッチヘルパーは `ConvertIpaWithProsodyBatchItem` の直後に `ConvertMisakiIpaBatchItem` を追加する。 @@ -277,14 +277,14 @@ T01-T02 が完了した `PinyinToMisaki.cs` を前提とするため、実装自 | テスト項目 | 内容 | 検証ポイント | |------------|------|-------------| -| 基本変換 | `engine.ToMisakiIpa("你好")` が空でない Misaki 互換 IPA を返す | パイプライン全体が接続されている | -| 声調付き | `engine.ToMisakiIpa("你好", true)` に声調マーカーが含まれる | includeTones=true の動作 | -| 声調なし | `engine.ToMisakiIpa("你好", false)` に声調マーカーが含まれない | includeTones=false の動作 | -| デフォルト声調 | `ToMisakiIpa(text)` と `ToMisakiIpa(text, true)` が同一結果 | デフォルト引数の一貫性 | +| 基本変換 | `engine.ToMisakiIPA("你好")` が空でない Misaki 互換 IPA を返す | パイプライン全体が接続されている | +| 声調付き | `engine.ToMisakiIPA("你好", true)` に声調マーカーが含まれる | includeTones=true の動作 | +| 声調なし | `engine.ToMisakiIPA("你好", false)` に声調マーカーが含まれない | includeTones=false の動作 | +| デフォルト声調 | `ToMisakiIPA(text)` と `ToMisakiIPA(text, true)` が同一結果 | デフォルト引数の一貫性 | | 声調変調 | 三声連続("你好")で声調変調が適用される | EnableToneSandhi 連携 | | 句読点区切り | `"你好,世界"` で句読点前後が正しく区切られる | FormatOutput の区切り処理 | -| 空文字入力 | `ToMisakiIpa("")` → `""` | 空入力ガード | -| null入力 | `ToMisakiIpa(null)` → `""` | null ガード(RunPipeline の IsNullOrWhiteSpace) | +| 空文字入力 | `ToMisakiIPA("")` → `""` | 空入力ガード | +| null入力 | `ToMisakiIPA(null)` → `""` | null ガード(RunPipeline の IsNullOrWhiteSpace) | | ASCII混在 | `"Hello你好"` で ASCII 部分がそのまま、漢字部分が IPA | 非漢字スルー | | Separator 設定 | `Separator = "-"` のオプションで区切り文字が変わる | Options.Separator の反映 | @@ -292,25 +292,25 @@ T01-T02 が完了した `PinyinToMisaki.cs` を前提とするため、実装自 | テスト項目 | 内容 | 検証ポイント | |------------|------|-------------| -| バッチ基本 | `ToMisakiIpaBatch(new[] {"你好", "世界"})` が2要素を返す | バッチ変換の動作 | -| バッチ声調制御 | `ToMisakiIpaBatch(texts, false)` で全要素が声調なし | includeTones バッチ転送 | -| バッチ空配列 | `ToMisakiIpaBatch(Array.Empty())` が空リスト | 空配列ガード | -| バッチ null | `ToMisakiIpaBatch(null)` で `ArgumentNullException` | BatchConversionHelper の null チェック | +| バッチ基本 | `ToMisakiIPABatch(new[] {"你好", "世界"})` が2要素を返す | バッチ変換の動作 | +| バッチ声調制御 | `ToMisakiIPABatch(texts, false)` で全要素が声調なし | includeTones バッチ転送 | +| バッチ空配列 | `ToMisakiIPABatch(Array.Empty())` が空リスト | 空配列ガード | +| バッチ null | `ToMisakiIPABatch(null)` で `ArgumentNullException` | BatchConversionHelper の null チェック | ### 4.3 異常系テスト | テスト項目 | 内容 | 検証ポイント | |------------|------|-------------| -| Dispose 後呼び出し | `engine.Dispose(); engine.ToMisakiIpa("你好")` → `ObjectDisposedException` | ThrowIfDisposed | -| Dispose 後バッチ | `engine.Dispose(); engine.ToMisakiIpaBatch(texts)` → `ObjectDisposedException` | バッチ側の ThrowIfDisposed | +| Dispose 後呼び出し | `engine.Dispose(); engine.ToMisakiIPA("你好")` → `ObjectDisposedException` | ThrowIfDisposed | +| Dispose 後バッチ | `engine.Dispose(); engine.ToMisakiIPABatch(texts)` → `ObjectDisposedException` | バッチ側の ThrowIfDisposed | ## 5. 実装に関する懸念事項とレビュー項目 ### 5.1 Dispose 済みチェック -- `ToMisakiIpa(string)` は `RunPipeline` 内で `ThrowIfDisposed()` が呼ばれるため、明示的なチェックは不要 -- `ToMisakiIpa(string, bool)` も同様に `RunPipeline` 経由で保護される -- `ToMisakiIpaBatch` は `RunPipeline` の前に `ThrowIfDisposed()` を明示的に呼ぶ(既存バッチ API と同一パターン)。これは `BatchConversionHelper.ConvertToList` が内部で個別に `ToMisakiIpa` を呼ぶ前にまずエンジンの状態を検証するため +- `ToMisakiIPA(string)` は `RunPipeline` 内で `ThrowIfDisposed()` が呼ばれるため、明示的なチェックは不要 +- `ToMisakiIPA(string, bool)` も同様に `RunPipeline` 経由で保護される +- `ToMisakiIPABatch` は `RunPipeline` の前に `ThrowIfDisposed()` を明示的に呼ぶ(既存バッチ API と同一パターン)。これは `BatchConversionHelper.ConvertToList` が内部で個別に `ToMisakiIPA` を呼ぶ前にまずエンジンの状態を検証するため - レビュー時に `ThrowIfDisposed()` の呼び出し漏れがないことを確認すること ### 5.2 スレッドセーフティ @@ -330,8 +330,8 @@ T01-T02 が完了した `PinyinToMisaki.cs` を前提とするため、実装自 ### 5.4 命名規則の一貫性 - 既存: `ToPiperIPA`("IPA" 全大文字)、`ToIPA`(全大文字) -- 新規: `ToMisakiIpa` — "Ipa" を PascalCase にする理由は、"Misaki" が固有名詞であり "MisakiIPA" だと "KIPA" のように読めてしまうため。ただし、既存の `ToPiperIPA` との整合性から `ToMisakiIPA` も検討すべき -- **レビュー項目**: チーム内で `ToMisakiIpa` vs `ToMisakiIPA` の命名を統一すること。本チケットでは Issue #56 の記載に従い `ToMisakiIpa` を採用する +- 新規: `ToMisakiIPA` — "Ipa" を PascalCase にする理由は、"Misaki" が固有名詞であり "MisakiIPA" だと "KIPA" のように読めてしまうため。ただし、既存の `ToPiperIPA` との整合性から `ToMisakiIPA` も検討すべき +- **レビュー項目**: チーム内で `ToMisakiIPA` vs `ToMisakiIPA` の命名を統一すること。本チケットでは Issue #56 の記載に従い `ToMisakiIPA` を採用する ### 5.5 PinyinToMisaki.Convert のシグネチャ互換性 @@ -353,7 +353,7 @@ ToZhuyin / ToZhuyinBatch (各2オーバーロード) ToPiperIPA / ToPiperIpaPhonemes / ToPiperIPABatch ToPuaPhonemes / ToPuaString / ToPuaStringBatch ToIpaWithProsody / ToIpaWithProsodyBatch (各2オーバーロード) -ToMisakiIpa / ToMisakiIpaBatch (各2オーバーロード) ← 今回追加 +ToMisakiIPA / ToMisakiIPABatch (各2オーバーロード) ← 今回追加 ``` 合計 30 メソッド以上のフラットな API サーフェスとなり、今後さらに出力形式が増えると管理が困難になる。 @@ -417,9 +417,9 @@ var result = engine.Convert("你好") | 項目 | §6 の評価 | 本レビューでの補足 | |------|----------|--------------------| -| メソッド数の把握 | 「30 メソッド以上」と概数 | 実測: 単一17 + バッチ11 + 辞書参照3 = **31 public メソッド**(`ChineseG2PEngine.cs` L104-L603)。`EnglishG2PEngine` (11 public) と比較して **約3倍**。今回の ToMisakiIpa 追加で 35 メソッドに増える | +| メソッド数の把握 | 「30 メソッド以上」と概数 | 実測: 単一17 + バッチ11 + 辞書参照3 = **31 public メソッド**(`ChineseG2PEngine.cs` L104-L603)。`EnglishG2PEngine` (11 public) と比較して **約3倍**。今回の ToMisakiIPA 追加で 35 メソッドに増える | | メソッド爆発の原因分析 | 「出力形式ごとに増殖」のみ | 実際には **3軸の直積** — (出力形式 × 引数バリエーション × 単一/バッチ)。例: IPA は (style × includeTones × 単一/List/Batch) = 8通り。この構造に言及がない | -| 命名の揺れ | `ToMisakiIpa` vs `ToMisakiIPA` のみ議論 | 既に **Chinese=`ToPiperIPA` (全大文字) / English=`ToPiperIpa` (PascalCase)** の破綻が存在 (`ChineseG2PEngine.cs:256` vs `EnglishG2PEngine.cs:345`)。パッケージ間の揺れこそが本質問題。§6 はこの既存矛盾に触れていない | +| 命名の揺れ | `ToMisakiIPA` vs `ToMisakiIPA` のみ議論 | 既に **Chinese=`ToPiperIPA` (全大文字) / English=`ToPiperIpa` (PascalCase)** の破綻が存在 (`ChineseG2PEngine.cs:256` vs `EnglishG2PEngine.cs:345`)。パッケージ間の揺れこそが本質問題。§6 はこの既存矛盾に触れていない | | 戦略パターン案の具体性 | インターフェース定義のみ | `PinyinSyllable` を渡すシグネチャになっているが、現行 `RunPipeline` は `Func` で**文字列のピンイン**を渡している。型が一致せず机上論にとどまっている | | フルエント API 案 | `ToFormat(OutputFormat.MisakiIpa)` | `enum` ベースだと `ToPinyinList(style)` 型の**配列戻り値**や `ToIpaWithProsody` 型の**構造体戻り値**を統一できない。ジェネリクスや型パラメータの議論が欠落 | | 段階的導入計画 | なし | 破壊的変更を避けるロードマップ、SemVer 上の位置付け、`[Obsolete]` の扱い方などが未記載 | @@ -589,7 +589,7 @@ public readonly struct ChineseConvertRequest | 段階 | バージョン | 作業内容 | API 破壊 | |------|-----------|---------|----------| -| **Phase 1** | v1.10.0 (Minor) | `ChineseOutputFormat` enum / `IPinyinStringConverter` / `Convert(text, format, options)` 追加。既存 `ToIPA` / `ToMisakiIpa` 等はそのまま残し、**内部で新 API に委譲**する。T03 の `ToMisakiIpa` もこの形で追加するだけでよい | なし | +| **Phase 1** | v1.10.0 (Minor) | `ChineseOutputFormat` enum / `IPinyinStringConverter` / `Convert(text, format, options)` 追加。既存 `ToIPA` / `ToMisakiIPA` 等はそのまま残し、**内部で新 API に委譲**する。T03 の `ToMisakiIPA` もこの形で追加するだけでよい | なし | | **Phase 2** | v1.11.0 (Minor) | Fluent API (`engine.For(...).AsMisakiIpa()` 等) を追加。XML ドキュメントで Fluent 版を推奨 | なし | | **Phase 3** | v1.12.0 (Minor) | 既存個別メソッドに `[Obsolete("v2.0 で削除予定。Convert(text, Format.X) を使用してください", error: false)]` を付与。CHANGELOG で周知 | なし(警告のみ) | | **Phase 4** | v2.0.0 (Major) | Obsolete 済みメソッドを削除。Fluent + `Convert(...)` の2系統のみに集約 | **あり** | @@ -597,7 +597,7 @@ public readonly struct ChineseConvertRequest **ポイント**: - Phase 1-3 はすべて **Minor** バージョンで完結 → ユーザーコードに影響なし - Phase 3 で `error: false` にすることで、ビルド警告としてのみ表示される(CI を壊さない) -- `ToMisakiIpa` は Phase 1 の一部として追加される「最後の個別メソッド」と位置付ける +- `ToMisakiIPA` は Phase 1 の一部として追加される「最後の個別メソッド」と位置付ける #### E. 他言語モジュールとの整合性 @@ -610,7 +610,7 @@ public readonly struct ChineseConvertRequest | Portuguese | `ToIPA` | `ToXSampa` | — | — | | Spanish | `ToIPA` | `ToXSampa` | — | — | -`ToPiperIPA` (Chinese) と `ToPiperIpa` (English) の **大小文字揺れ** が既に存在する。これはコードレビューで見落とされた既存バグであり、T03 で `ToMisakiIpa` を追加する際に **英語側のスタイル (`ToMisakiIpa`, PascalCase)** に合わせることで、新規追加分は正しい方向に揃う。 +`ToPiperIPA` (Chinese) と `ToPiperIpa` (English) の **大小文字揺れ** が既に存在する。これはコードレビューで見落とされた既存バグであり、T03 で `ToMisakiIPA` を追加する際に **英語側のスタイル (`ToMisakiIPA`, PascalCase)** に合わせることで、新規追加分は正しい方向に揃う。 **推奨**: - 今後の命名規則を **「3文字以上の頭字語は PascalCase」** と明文化(.NET 設計ガイドライン準拠: `Xml`, `Html`, `Ipa`) @@ -619,9 +619,9 @@ public readonly struct ChineseConvertRequest #### F. T03 への即時推奨 -本チケット (T03) のスコープはあくまで `ToMisakiIpa` 追加のため、上記リファクタはこのチケットでは**行わない**。ただし以下の点は T03 実装時点で配慮すべき: +本チケット (T03) のスコープはあくまで `ToMisakiIPA` 追加のため、上記リファクタはこのチケットでは**行わない**。ただし以下の点は T03 実装時点で配慮すべき: -1. **命名**: `ToMisakiIpa` (PascalCase) を採用 → 既に §5.4 で決定済みで正解 +1. **命名**: `ToMisakiIPA` (PascalCase) を採用 → 既に §5.4 で決定済みで正解 2. **型シグネチャ**: 将来 Strategy 実装 (`MisakiIpaStrategy`) に置き換えやすいよう、`PinyinToMisaki.Convert` は引き続き **静的メソッド + ステートレス** を維持する 3. **XML ドキュメント**: `` / `` を追加し、相互参照により IntelliSense でのディスカバリを改善 4. **新規 enum 追加の先取り**: T03 では不要だが、次のチケット (T05 以降) で `ChineseOutputFormat` 追加を計画に入れておくと、Phase 1 への移行コストが下がる @@ -638,7 +638,7 @@ public readonly struct ChineseConvertRequest 問題点: -- `IIpaTextBatchProcessor` は「標準 IPA」しか表現できない。`ToMisakiIpa` を ChineseG2PEngine に追加しても、Multilingual 経由で呼び出す経路が存在しない +- `IIpaTextBatchProcessor` は「標準 IPA」しか表現できない。`ToMisakiIPA` を ChineseG2PEngine に追加しても、Multilingual 経由で呼び出す経路が存在しない - Issue #56 の本質は「Kokoro TTS 用の Misaki 互換出力を多言語混在テキストから取得したい」であり、Multilingual 層に Misaki 能力が伝搬しない限り、ユーザーは中英混在テキストを手動でセグメント分割して ChineseG2PEngine を個別にインスタンス化する必要がある - 将来 Kokoro 対応を英語・日本語に拡張する際、能力インターフェースが爆発する(`IMisakiEnglishTextBatchProcessor` / `IMisakiJapaneseTextBatchProcessor` 等) @@ -649,22 +649,22 @@ public readonly struct ChineseConvertRequest // 追加: Misaki プロファイル(標準 IPA とは別軸の能力) internal interface IMisakiCapableProcessor { - string ConvertToMisakiIpa(string text, bool includeTones = true); - IReadOnlyList ConvertToMisakiIpaBatch( + string ConvertToMisakiIPA(string text, bool includeTones = true); + IReadOnlyList ConvertToMisakiIPABatch( IReadOnlyList texts, bool includeTones = true); } // DelegateIpaTextBatchProcessor と同様に DelegateMisakiTextBatchProcessor を追加 // LanguageCapabilityRouter.CreateLazy に Chinese 用の Misaki デリゲートを注入 -// text => lazyChineseEngine.Value.ToMisakiIpa(text), -// texts => lazyChineseEngine.Value.ToMisakiIpaBatch(texts.ToArray()) +// text => lazyChineseEngine.Value.ToMisakiIPA(text), +// texts => lazyChineseEngine.Value.ToMisakiIPABatch(texts.ToArray()) ``` -`LanguageCapabilityRouter.TryGetMisaki(Language, out IMisakiCapableProcessor?)` を追加し、`MultilingualG2PEngine.ToMisakiIpa(string text)` が Chinese セグメントだけを Misaki で処理し、他言語セグメントは現状の IPA でフォールバックする(または将来的に各言語の Misaki 変換で置き換える)。`TryGetMisakiIpa` が `false` を返せばフォールバック経路を明示できる設計にする。 +`LanguageCapabilityRouter.TryGetMisaki(Language, out IMisakiCapableProcessor?)` を追加し、`MultilingualG2PEngine.ToMisakiIPA(string text)` が Chinese セグメントだけを Misaki で処理し、他言語セグメントは現状の IPA でフォールバックする(または将来的に各言語の Misaki 変換で置き換える)。`TryGetMisakiIpa` が `false` を返せばフォールバック経路を明示できる設計にする。 -**T03 への具体影響**: 本チケットで追加する `ToMisakiIpa` / `ToMisakiIpaBatch` のシグネチャは、後述の `IMisakiCapableProcessor` の契約と 1:1 で対応させること。具体的には: +**T03 への具体影響**: 本チケットで追加する `ToMisakiIPA` / `ToMisakiIPABatch` のシグネチャは、後述の `IMisakiCapableProcessor` の契約と 1:1 で対応させること。具体的には: -- メソッド名は `ToMisakiIpa`(`ToMisakiIPA` ではない)で統一 → 将来の `IMisakiCapableProcessor.ConvertToMisakiIpa` との整合性を担保(これは §API設計の追加レビュー §E の「3文字以上の頭字語は PascalCase」方針とも一致) +- メソッド名は `ToMisakiIPA`(`ToMisakiIPA` ではない)で統一 → 将来の `IMisakiCapableProcessor.ConvertToMisakiIPA` との整合性を担保(これは §API設計の追加レビュー §E の「3文字以上の頭字語は PascalCase」方針とも一致) - `includeTones` パラメータの既定値は `true` → Kokoro Python の `misaki` デフォルトと一致、`IMisakiCapableProcessor` と揃える - バッチ版の戻り値は `IReadOnlyList` を維持 → Multilingual の既存契約(`ITextBatchProcessor.ConvertBatch`)と同型なので、将来のアダプタ実装でシグネチャ変換が不要 @@ -678,7 +678,7 @@ internal interface IMisakiCapableProcessor 4. **トリム警告(IL2026 / IL3050)**: `PinyinToMisaki` が reflection や dynamic code generation を使わない限り、新規警告は発生しない。T02 で実装する `PinyinToMisaki.Convert` がリフレクションベースの辞書ロード(例: `Assembly.GetManifestResourceStream` 経由で辞書を遅延ロード)を含む場合は、`[RequiresUnreferencedCode]` 属性の付与と `ILLink.xml` の更新が必要になる可能性がある 5. **`ILLink.xml` の更新は原則不要**: `[Preserve]` 属性で十分。ただし将来 `IMisakiCapableProcessor` をリフレクションで解決する設計に移行する場合は、`ILLink.xml` に `` を明示することを検討 -**レビュー項目追加**: T03 実装後、Unity 2022.3 LTS + IL2CPP + iOS/Android Build Target の組み合わせで `engine.ToMisakiIpa("你好")` がランタイムで動作することを簡易確認すること。具体的には UPM パッケージ `com.dotnetg2p.chinese` を含む空の Unity プロジェクトで IL2CPP ビルドし、起動時に例外が出ないことを Log で確認する。Unity Editor の Managed Stripping Level は `Low` / `Medium` / `High` の 3 段階を順に試すことが理想だが、最低でも `Medium`(デフォルト)で通ることを保証する。 +**レビュー項目追加**: T03 実装後、Unity 2022.3 LTS + IL2CPP + iOS/Android Build Target の組み合わせで `engine.ToMisakiIPA("你好")` がランタイムで動作することを簡易確認すること。具体的には UPM パッケージ `com.dotnetg2p.chinese` を含む空の Unity プロジェクトで IL2CPP ビルドし、起動時に例外が出ないことを Log で確認する。Unity Editor の Managed Stripping Level は `Low` / `Medium` / `High` の 3 段階を順に試すことが理想だが、最低でも `Medium`(デフォルト)で通ることを保証する。 #### C. KokoroSharp 統合のサンプルコード(ユーザー視点の期待) @@ -690,7 +690,7 @@ using KokoroSharp; // DotNetG2P 側: Misaki 互換 IPA を取得 using var g2p = new ChineseG2PEngine(); -string misakiIpa = g2p.ToMisakiIpa("你好世界"); +string misakiIpa = g2p.ToMisakiIPA("你好世界"); // 出力例: "ni↗ xau̯↓ ʂɨ↘ ʨie↘" // KokoroSharp 側: Misaki IPA を直接フィード @@ -703,7 +703,7 @@ File.WriteAllBytes("output.wav", audioSamples.ToWav()); ```csharp var lines = new[] { "你好", "世界", "再见" }; -IReadOnlyList misakiLines = g2p.ToMisakiIpaBatch(lines); +IReadOnlyList misakiLines = g2p.ToMisakiIPABatch(lines); // 各要素が Misaki 互換 IPA。順序は入力配列と 1:1 対応 foreach (var ipa in misakiLines) { @@ -724,7 +724,7 @@ if (multi.TryGetMisakiIpa("你好 Hello 世界", out string? misaki)) } ``` -**T03 への反映**: 上記サンプルが動作するために本チケットで必要な公開 API は `ToMisakiIpa(string)` / `ToMisakiIpaBatch(string[])` の 2 つのみ(既存スコープに含まれる)。ただし XML ドキュメンテーションコメントに「KokoroSharp 等の Kokoro TTS C# 実装にそのまま入力可能」の 1 行を `` に追加すると、IntelliSense で用途が明確になりユーザー体験が向上する。具体的には: +**T03 への反映**: 上記サンプルが動作するために本チケットで必要な公開 API は `ToMisakiIPA(string)` / `ToMisakiIPABatch(string[])` の 2 つのみ(既存スコープに含まれる)。ただし XML ドキュメンテーションコメントに「KokoroSharp 等の Kokoro TTS C# 実装にそのまま入力可能」の 1 行を `` に追加すると、IntelliSense で用途が明確になりユーザー体験が向上する。具体的には: ```csharp /// @@ -734,7 +734,7 @@ if (multi.TryGetMisakiIpa("你好 Hello 世界", out string? misaki)) /// 出力形式は Python の misaki ライブラリと互換性があり、 /// KokoroSharp などの Kokoro TTS C# 実装にそのまま入力可能です。 /// -public string ToMisakiIpa(string text) { ... } +public string ToMisakiIPA(string text) { ... } ``` #### D. NuGet / UPM 両配布における制約 @@ -755,20 +755,20 @@ Kokoro TTS は中国語以外に日本語・英語・韓国語・スペイン語 | 言語 | API 例 | 備考 | |------|--------|------| -| 中国語 | `ChineseG2PEngine.ToMisakiIpa(string)` | **T03 で実装** | -| 英語 | `EnglishG2PEngine.ToMisakiIpa(string)` | misaki-en の置換規則を適用 | -| 日本語 | `G2PEngine.ToMisakiIpa(string)` | misaki-ja (OpenJTalk ベース) の置換規則 | -| 韓国語 | `KoreanG2PEngine.ToMisakiIpa(string)` | Hangul-first で Misaki 互換 | -| スペイン語 | `SpanishG2PEngine.ToMisakiIpa(string)` | ipa-dict ベースに Kokoro 変換 | -| フランス語 | `FrenchG2PEngine.ToMisakiIpa(string)` | 例外辞書 + Kokoro 変換 | -| ポルトガル語 | `PortugueseG2PEngine.ToMisakiIpa(string)` | 同上 | -| スウェーデン語 | `SwedishG2PEngine.ToMisakiIpa(string)` | Kokoro 未対応だが将来に備え予約 | +| 中国語 | `ChineseG2PEngine.ToMisakiIPA(string)` | **T03 で実装** | +| 英語 | `EnglishG2PEngine.ToMisakiIPA(string)` | misaki-en の置換規則を適用 | +| 日本語 | `G2PEngine.ToMisakiIPA(string)` | misaki-ja (OpenJTalk ベース) の置換規則 | +| 韓国語 | `KoreanG2PEngine.ToMisakiIPA(string)` | Hangul-first で Misaki 互換 | +| スペイン語 | `SpanishG2PEngine.ToMisakiIPA(string)` | ipa-dict ベースに Kokoro 変換 | +| フランス語 | `FrenchG2PEngine.ToMisakiIPA(string)` | 例外辞書 + Kokoro 変換 | +| ポルトガル語 | `PortugueseG2PEngine.ToMisakiIPA(string)` | 同上 | +| スウェーデン語 | `SwedishG2PEngine.ToMisakiIPA(string)` | Kokoro 未対応だが将来に備え予約 | **統一規則**: -- メソッド名: `ToMisakiIpa` で固定(「Ipa」は PascalCase)。`ToMisakiIPA` は採用しない → T03 の §5.4 の決定を全言語に適用(これは §API設計の追加レビュー §E で指摘された既存の `ToPiperIPA` vs `ToPiperIpa` 揺れ問題の修正方向とも一致) -- オーバーロード: `ToMisakiIpa(string text)` と `ToMisakiIpa(string text, bool includeTones)` の 2 種類を全言語で提供 -- バッチ版: `ToMisakiIpaBatch(string[] texts)` と `ToMisakiIpaBatch(string[] texts, bool includeTones)` +- メソッド名: `ToMisakiIPA` で固定(「Ipa」は PascalCase)。`ToMisakiIPA` は採用しない → T03 の §5.4 の決定を全言語に適用(これは §API設計の追加レビュー §E で指摘された既存の `ToPiperIPA` vs `ToPiperIpa` 揺れ問題の修正方向とも一致) +- オーバーロード: `ToMisakiIPA(string text)` と `ToMisakiIPA(string text, bool includeTones)` の 2 種類を全言語で提供 +- バッチ版: `ToMisakiIPABatch(string[] texts)` と `ToMisakiIPABatch(string[] texts, bool includeTones)` - 名前空間: 各言語パッケージの `Conversion` 名前空間内に `PinyinToMisaki` / `CmuToMisaki` / `KanaToMisaki` / `HangulToMisaki` のような対応クラスを配置(言語ごとに中間表現は異なるが、クラス名の末尾 `ToMisaki` で命名を揃える) **T03 の構造的寄与**: 本チケットの実装パターン(`RunPipeline` + 静的変換クラス + `BatchConversionHelper` 利用)は、他言語の Misaki 対応にそのまま再利用可能なテンプレートとなる。コードレビュー時に「この実装パターンが他言語でも再現可能か?」を確認し、困難な箇所があれば T03 の段階で `Conversion/PinyinToMisaki.cs` の命名・責務分離を修正すること。特に `PinyinToMisaki.Convert(pinyin, includeTones)` の静的メソッドシグネチャは、他言語のシングルエントリとして引数型を除いて**完全に同じ形**になる設計を目指す。 @@ -780,7 +780,7 @@ Kokoro TTS は中国語以外に日本語・英語・韓国語・スペイン語 - `ToMisakiPhonemes` → 出力は「音素」ではなく IPA 文字列なので不正確(既存の `ToPhonemes` が「音素列」を返すメソッドと衝突する) - `ToIPA(IpaFormat.Misaki)` のようなオプション経由 → 他言語展開時に `IpaFormat` 列挙型が肥大化し、§API設計の追加レビュー §B の戦略パターン案(`ChineseOutputFormat` enum)と衝突する。Kokoro 互換は独立した "音素系の変換軸" であり、`Format` enum には混ぜないこと -**§API設計の追加レビュー との整合性**: §B の `ChineseOutputFormat.MisakiIpa` と本節の `ToMisakiIpa` メソッドは、**Phase 1 (v1.10.0)** 時点では共存する設計となる — すなわち `ToMisakiIpa(text)` の内部実装が `Convert(text, ChineseOutputFormat.MisakiIpa)` を呼び出す、という委譲関係になる。この 2 つの視点(クラス内部の型設計 / 外部配布・統合)は矛盾せず、同じ Phase 1 計画のもとで同期的に実装可能である。 +**§API設計の追加レビュー との整合性**: §B の `ChineseOutputFormat.MisakiIpa` と本節の `ToMisakiIPA` メソッドは、**Phase 1 (v1.10.0)** 時点では共存する設計となる — すなわち `ToMisakiIPA(text)` の内部実装が `Convert(text, ChineseOutputFormat.MisakiIpa)` を呼び出す、という委譲関係になる。この 2 つの視点(クラス内部の型設計 / 外部配布・統合)は矛盾せず、同じ Phase 1 計画のもとで同期的に実装可能である。 ## 7. 後続タスクへの連絡事項 @@ -792,7 +792,7 @@ T04(テスト実装)担当者への伝達事項: 4. **声調変調の検証**: `"你好"` は三声連続(nǐ + hǎo)で声調変調が適用される(nǐ → ní)。声調変調後の Misaki IPA 出力が正しいことを検証するテストを必ず含めること 5. **Separator テスト**: `ChineseG2POptions` の `Separator` プロパティが Misaki 出力にも反映されることを確認するテストを含めること。デフォルト(スペース区切り)と カスタム区切り文字の両方をテストする 6. **Dispose テスト**: `ObjectDisposedException` のテストは `Assert.Throws` で検証する。単一 API とバッチ API の両方で確認すること -7. **バッチ API の戻り値型**: `ToMisakiIpaBatch` の戻り値は `IReadOnlyList` であることに注意。`List` や `string[]` ではない +7. **バッチ API の戻り値型**: `ToMisakiIPABatch` の戻り値は `IReadOnlyList` であることに注意。`List` や `string[]` ではない ## 8. 紐づけ diff --git a/docs/tickets/T04-misaki-tests.md b/docs/tickets/T04-misaki-tests.md index 6b644a3..8cdddfe 100644 --- a/docs/tickets/T04-misaki-tests.md +++ b/docs/tickets/T04-misaki-tests.md @@ -2,7 +2,7 @@ ticket: T04 title: Misaki互換テスト実装 milestone: Mi2 -status: 未着手 +status: 完了 depends_on: [T03] blocks: [T05] --- @@ -12,7 +12,7 @@ blocks: [T05] ## 1. タスク目的とゴール ### 目的 -Mi2マイルストーンの2枚目として、T01-T03で実装された `PinyinToMisaki` 変換クラスおよび `ChineseG2PEngine.ToMisakiIpa()` / `ToMisakiIpaBatch()` API の品質を、網羅的な単体テストとエンドツーエンドテストで保証する。 +Mi2マイルストーンの2枚目として、T01-T03で実装された `PinyinToMisaki` 変換クラスおよび `ChineseG2PEngine.ToMisakiIPA()` / `ToMisakiIPABatch()` API の品質を、網羅的な単体テストとエンドツーエンドテストで保証する。 ### ゴール 1. **Misaki互換出力の正確性保証**: 声調マッピング(矢印記号)、声母マッピング(`j/q→ʨ/ʨʰ`, `z/c→ʦ/ʦʰ`)、韻母マッピング(二重母音の非音節化符号 `i̯/u̯`)がすべて仕様通りに出力されること @@ -24,7 +24,7 @@ Mi2マイルストーンの2枚目として、T01-T03で実装された `PinyinT ### 成功指標 - `ChineseMisakiIpaTests` が 100% パスする(最低35件以上のテストを想定) - 既存 `dotnet test DotNetG2P.slnx` が全件パスする(936件 + 新規テスト) -- `ToMisakiIpa("你好")` が Misaki と同等の出力を返す +- `ToMisakiIPA("你好")` が Misaki と同等の出力を返す - 全エッジケースで例外が発生しない --- @@ -48,7 +48,7 @@ namespace DotNetG2P.Tests.ChineseG2P { /// /// Misaki (Kokoro TTS) 互換 IPA 変換の正確性を検証するテスト。 - /// ChineseG2PEngine の ToMisakiIpa() メソッド経由で、 + /// ChineseG2PEngine の ToMisakiIPA() メソッド経由で、 /// Misaki方式の声母・韻母IPAマッピング、特殊母音、声調矢印記号を検証する。 /// public class ChineseMisakiIpaTests : IDisposable @@ -76,13 +76,13 @@ namespace DotNetG2P.Tests.ChineseG2P | # | メソッド名 | 入力 | 期待出力/Assert | 備考 | |---|----------|------|---------------|------| -| 1.1 | `ToMisakiIpa_第1声_矢印右向き` | `"妈"` (mā) | `"ma\u2192"` (`ma→`) | `Assert.Equal` 完全一致 | -| 1.2 | `ToMisakiIpa_第2声_矢印右上向き` | `"麻"` (má) | `"ma\u2197"` (`ma↗`) | `Assert.Equal` 完全一致 | -| 1.3 | `ToMisakiIpa_第3声_矢印下向き` | `"马"` (mǎ, 単字で変調なし) | `"ma\u2193"` (`ma↓`) | `Assert.Equal` 完全一致 | -| 1.4 | `ToMisakiIpa_第4声_矢印右下向き` | `"骂"` (mà) | `"ma\u2198"` (`ma↘`) | `Assert.Equal` 完全一致 | -| 1.5 | `ToMisakiIpa_軽声_矢印なし` | `"吗"` (ma, 軽声) | `"ma"`(矢印なし) | `Assert.DoesNotContain` で各矢印を検証 | -| 1.6 | `ToMisakiIpa_IncludeTonesFalse_矢印なし` | `"妈"`, `includeTones=false` | `"ma"`(矢印なし) | `Assert.Equal` | -| 1.7 | `ToMisakiIpa_IPA声調letterを含まない` (Theory) | `"妈麻马骂"` 各1字 | `˥ ˦ ˧ ˨ ˩` を含まない | `Assert.DoesNotContain` 5回 | +| 1.1 | `ToMisakiIPA_第1声_矢印右向き` | `"妈"` (mā) | `"ma\u2192"` (`ma→`) | `Assert.Equal` 完全一致 | +| 1.2 | `ToMisakiIPA_第2声_矢印右上向き` | `"麻"` (má) | `"ma\u2197"` (`ma↗`) | `Assert.Equal` 完全一致 | +| 1.3 | `ToMisakiIPA_第3声_矢印下向き` | `"马"` (mǎ, 単字で変調なし) | `"ma\u2193"` (`ma↓`) | `Assert.Equal` 完全一致 | +| 1.4 | `ToMisakiIPA_第4声_矢印右下向き` | `"骂"` (mà) | `"ma\u2198"` (`ma↘`) | `Assert.Equal` 完全一致 | +| 1.5 | `ToMisakiIPA_軽声_矢印なし` | `"吗"` (ma, 軽声) | `"ma"`(矢印なし) | `Assert.DoesNotContain` で各矢印を検証 | +| 1.6 | `ToMisakiIPA_IncludeTonesFalse_矢印なし` | `"妈"`, `includeTones=false` | `"ma"`(矢印なし) | `Assert.Equal` | +| 1.7 | `ToMisakiIPA_IPA声調letterを含まない` (Theory) | `"妈麻马骂"` 各1字 | `˥ ˦ ˧ ˨ ˩` を含まない | `Assert.DoesNotContain` 5回 | **Theory 例:** ```csharp @@ -91,9 +91,9 @@ namespace DotNetG2P.Tests.ChineseG2P [InlineData("\u9EBB", "ma\u2197")] // 麻 → ma↗ [InlineData("\u9A6C", "ma\u2193")] // 马 → ma↓ [InlineData("\u9A82", "ma\u2198")] // 骂 → ma↘ -public void ToMisakiIpa_声調マッピング網羅(string hanzi, string expected) +public void ToMisakiIPA_声調マッピング網羅(string hanzi, string expected) { - var result = _engine.ToMisakiIpa(hanzi); + var result = _engine.ToMisakiIPA(hanzi); Assert.Equal(expected, result); } ``` @@ -102,14 +102,14 @@ public void ToMisakiIpa_声調マッピング網羅(string hanzi, string expecte | # | メソッド名 | 入力 | 期待Assert | 備考 | |---|----------|------|-----------|------| -| 2.1 | `ToMisakiIpa_j声母_ʨを返す` | `"几"` (jǐ) | `Assert.Contains("\uA7B3", result)` ではなく `"\u02A8"` (ʨ, U+02A8) | Misaki固有: `tɕ` → `ʨ` | -| 2.2 | `ToMisakiIpa_q声母_ʨʰを返す` | `"七"` (qī) | `Assert.Contains("\u02A8\u02B0", result)` (ʨʰ) | Misaki固有: `tɕʰ` → `ʨʰ` | -| 2.3 | `ToMisakiIpa_x声母_ɕを返す` | `"西"` (xī) | `Assert.Contains("\u0255", result)` (ɕ) | 標準IPAと共通 | -| 2.4 | `ToMisakiIpa_z声母_ʦを返す` | `"在"` (zài) | `Assert.Contains("\u02A6", result)` (ʦ, U+02A6) | Misaki固有: `ts` → `ʦ` | -| 2.5 | `ToMisakiIpa_c声母_ʦʰを返す` | `"才"` (cái) | `Assert.Contains("\u02A6\u02B0", result)` (ʦʰ) | Misaki固有: `tsʰ` → `ʦʰ` | -| 2.6 | `ToMisakiIpa_s声母_sを返す` | `"三"` (sān) | `Assert.Contains("s", result)` | 標準IPAと共通 | -| 2.7 | `ToMisakiIpa_zh声母_ʈʂを返す` | `"知"` (zhī) | `Assert.Contains("\u0288\u0282", result)` (ʈʂ) | 標準IPAと同じ | -| 2.8 | `ToMisakiIpa_ch声母_ʈʂʰを返す` | `"吃"` (chī) | `Assert.Contains("\u0288\u0282\u02B0", result)` (ʈʂʰ) | 標準IPAと同じ | +| 2.1 | `ToMisakiIPA_j声母_ʨを返す` | `"几"` (jǐ) | `Assert.Contains("\uA7B3", result)` ではなく `"\u02A8"` (ʨ, U+02A8) | Misaki固有: `tɕ` → `ʨ` | +| 2.2 | `ToMisakiIPA_q声母_ʨʰを返す` | `"七"` (qī) | `Assert.Contains("\u02A8\u02B0", result)` (ʨʰ) | Misaki固有: `tɕʰ` → `ʨʰ` | +| 2.3 | `ToMisakiIPA_x声母_ɕを返す` | `"西"` (xī) | `Assert.Contains("\u0255", result)` (ɕ) | 標準IPAと共通 | +| 2.4 | `ToMisakiIPA_z声母_ʦを返す` | `"在"` (zài) | `Assert.Contains("\u02A6", result)` (ʦ, U+02A6) | Misaki固有: `ts` → `ʦ` | +| 2.5 | `ToMisakiIPA_c声母_ʦʰを返す` | `"才"` (cái) | `Assert.Contains("\u02A6\u02B0", result)` (ʦʰ) | Misaki固有: `tsʰ` → `ʦʰ` | +| 2.6 | `ToMisakiIPA_s声母_sを返す` | `"三"` (sān) | `Assert.Contains("s", result)` | 標準IPAと共通 | +| 2.7 | `ToMisakiIPA_zh声母_ʈʂを返す` | `"知"` (zhī) | `Assert.Contains("\u0288\u0282", result)` (ʈʂ) | 標準IPAと同じ | +| 2.8 | `ToMisakiIPA_ch声母_ʈʂʰを返す` | `"吃"` (chī) | `Assert.Contains("\u0288\u0282\u02B0", result)` (ʈʂʰ) | 標準IPAと同じ | **Theory による声母網羅:** ```csharp @@ -131,9 +131,9 @@ public void ToMisakiIpa_声調マッピング網羅(string hanzi, string expecte [InlineData("\u5E72", "k")] // 干 (gān): g → k (共通) [InlineData("\u770B", "k\u02B0")] // 看 (kàn): k → kʰ (共通) [InlineData("\u597D", "x")] // 好 (hǎo): h → x (共通) -public void ToMisakiIpa_声母マッピング網羅(string hanzi, string expectedInitialIpa) +public void ToMisakiIPA_声母マッピング網羅(string hanzi, string expectedInitialIpa) { - var result = _engine.ToMisakiIpa(hanzi); + var result = _engine.ToMisakiIPA(hanzi); Assert.Contains(expectedInitialIpa, result); } ``` @@ -144,15 +144,15 @@ public void ToMisakiIpa_声母マッピング網羅(string hanzi, string expecte | # | メソッド名 | 入力 | 期待Assert | 備考 | |---|----------|------|-----------|------| -| 3.1 | `ToMisakiIpa_ai韻母_i非音節化を返す` | `"爱"` (ài) | `Assert.Contains("ai\u032F", result)` | DotNetG2P `aɪ` → Misaki `ai̯` | -| 3.2 | `ToMisakiIpa_ei韻母_i非音節化を返す` | `"北"` (běi) | `Assert.Contains("ei\u032F", result)` | DotNetG2P `eɪ` → Misaki `ei̯` | -| 3.3 | `ToMisakiIpa_ao韻母_u非音節化を返す` | `"好"` (hǎo, 単字) | `Assert.Contains("au\u032F", result)` | DotNetG2P `aʊ` → Misaki `au̯` | -| 3.4 | `ToMisakiIpa_ou韻母_u非音節化を返す` | `"走"` (zǒu) | `Assert.Contains("ou\u032F", result)` | DotNetG2P `oʊ` → Misaki `ou̯` | -| 3.5 | `ToMisakiIpa_iao韻母_u非音節化を返す` | `"小"` (xiǎo) | `Assert.Contains("iau\u032F", result)` | 3母音韻母 | -| 3.6 | `ToMisakiIpa_iu韻母_iou非音節化を返す` | `"六"` (liù) | `Assert.Contains("iou\u032F", result)` | DotNetG2P `ioʊ` → Misaki `iou̯` | -| 3.7 | `ToMisakiIpa_uai韻母_非音節化を返す` | `"怀"` (huái) | `Assert.Contains("uai\u032F", result)` | 3母音韻母 | -| 3.8 | `ToMisakiIpa_ui韻母_uei非音節化を返す` | `"对"` (duì) | `Assert.Contains("uei\u032F", result)` | DotNetG2P `ueɪ` → Misaki `uei̯` | -| 3.9 | `ToMisakiIpa_単母音a_非音節化符号なし` | `"啊"` (ā) | `Assert.DoesNotContain("\u032F", result)` | 単母音には付かないこと | +| 3.1 | `ToMisakiIPA_ai韻母_i非音節化を返す` | `"爱"` (ài) | `Assert.Contains("ai\u032F", result)` | DotNetG2P `aɪ` → Misaki `ai̯` | +| 3.2 | `ToMisakiIPA_ei韻母_i非音節化を返す` | `"北"` (běi) | `Assert.Contains("ei\u032F", result)` | DotNetG2P `eɪ` → Misaki `ei̯` | +| 3.3 | `ToMisakiIPA_ao韻母_u非音節化を返す` | `"好"` (hǎo, 単字) | `Assert.Contains("au\u032F", result)` | DotNetG2P `aʊ` → Misaki `au̯` | +| 3.4 | `ToMisakiIPA_ou韻母_u非音節化を返す` | `"走"` (zǒu) | `Assert.Contains("ou\u032F", result)` | DotNetG2P `oʊ` → Misaki `ou̯` | +| 3.5 | `ToMisakiIPA_iao韻母_u非音節化を返す` | `"小"` (xiǎo) | `Assert.Contains("iau\u032F", result)` | 3母音韻母 | +| 3.6 | `ToMisakiIPA_iu韻母_iou非音節化を返す` | `"六"` (liù) | `Assert.Contains("iou\u032F", result)` | DotNetG2P `ioʊ` → Misaki `iou̯` | +| 3.7 | `ToMisakiIPA_uai韻母_非音節化を返す` | `"怀"` (huái) | `Assert.Contains("uai\u032F", result)` | 3母音韻母 | +| 3.8 | `ToMisakiIPA_ui韻母_uei非音節化を返す` | `"对"` (duì) | `Assert.Contains("uei\u032F", result)` | DotNetG2P `ueɪ` → Misaki `uei̯` | +| 3.9 | `ToMisakiIPA_単母音a_非音節化符号なし` | `"啊"` (ā) | `Assert.DoesNotContain("\u032F", result)` | 単母音には付かないこと | **Theory での網羅:** ```csharp @@ -165,9 +165,9 @@ public void ToMisakiIpa_声母マッピング網羅(string hanzi, string expecte [InlineData("\u516D", "iou\u032F")] // 六 (liù) [InlineData("\u6000", "uai\u032F")] // 怀 (huái) [InlineData("\u5BF9", "uei\u032F")] // 对 (duì) -public void ToMisakiIpa_二重母音非音節化符号マッピング(string hanzi, string expectedFinal) +public void ToMisakiIPA_二重母音非音節化符号マッピング(string hanzi, string expectedFinal) { - var result = _engine.ToMisakiIpa(hanzi); + var result = _engine.ToMisakiIPA(hanzi); Assert.Contains(expectedFinal, result); } ``` @@ -180,43 +180,43 @@ Misaki 設計ドキュメントより: `zh/ch/sh/r+i → ɻ̩` / `ʐ̩`, `z/c/s+ | # | メソッド名 | 入力 | 期待Assert | 備考 | |---|----------|------|-----------|------| -| 4.1 | `ToMisakiIpa_zi_歯茎母音を含む` | `"子"` (zǐ) | `Assert.Contains("\u0279\u0329", result)` (ɹ̩) | z+i | -| 4.2 | `ToMisakiIpa_ci_歯茎母音を含む` | `"次"` (cì) | `Assert.Contains("\u0279\u0329", result)` (ɹ̩) | c+i | -| 4.3 | `ToMisakiIpa_si_歯茎母音を含む` | `"四"` (sì) | `Assert.Contains("\u0279\u0329", result)` (ɹ̩) | s+i | -| 4.4 | `ToMisakiIpa_zhi_そり舌母音を含む` | `"知"` (zhī) | `Assert.Contains("\u027B\u0329", result)` (ɻ̩) | zh+i | -| 4.5 | `ToMisakiIpa_chi_そり舌母音を含む` | `"吃"` (chī) | `Assert.Contains("\u027B\u0329", result)` (ɻ̩) | ch+i | -| 4.6 | `ToMisakiIpa_shi_そり舌母音を含む` | `"十"` (shí) | `Assert.Contains("\u027B\u0329", result)` (ɻ̩) | sh+i | -| 4.7 | `ToMisakiIpa_ri_そり舌母音を含む` | `"日"` (rì) | `Assert.Contains("\u027B\u0329", result)` (ɻ̩) | r+i | +| 4.1 | `ToMisakiIPA_zi_歯茎母音を含む` | `"子"` (zǐ) | `Assert.Contains("\u0279\u0329", result)` (ɹ̩) | z+i | +| 4.2 | `ToMisakiIPA_ci_歯茎母音を含む` | `"次"` (cì) | `Assert.Contains("\u0279\u0329", result)` (ɹ̩) | c+i | +| 4.3 | `ToMisakiIPA_si_歯茎母音を含む` | `"四"` (sì) | `Assert.Contains("\u0279\u0329", result)` (ɹ̩) | s+i | +| 4.4 | `ToMisakiIPA_zhi_そり舌母音を含む` | `"知"` (zhī) | `Assert.Contains("\u027B\u0329", result)` (ɻ̩) | zh+i | +| 4.5 | `ToMisakiIPA_chi_そり舌母音を含む` | `"吃"` (chī) | `Assert.Contains("\u027B\u0329", result)` (ɻ̩) | ch+i | +| 4.6 | `ToMisakiIPA_shi_そり舌母音を含む` | `"十"` (shí) | `Assert.Contains("\u027B\u0329", result)` (ɻ̩) | sh+i | +| 4.7 | `ToMisakiIPA_ri_そり舌母音を含む` | `"日"` (rì) | `Assert.Contains("\u027B\u0329", result)` (ɻ̩) | r+i | #### セクション 5: 声調変調(三声連読、一/不変調) | # | メソッド名 | 入力 | 期待Assert | 備考 | |---|----------|------|-----------|------| -| 5.1 | `ToMisakiIpa_三声連読_你好_前字が二声矢印` | `"你好"` | `Assert.Contains("ni\u2197", result)` (ni↗) | 3+3 → 2+3 変調 | -| 5.2 | `ToMisakiIpa_三声連読_你好_後字が三声矢印保持` | `"你好"` | `Assert.Contains("\u2193", result)` (↓) | 後字の三声矢印は残る | -| 5.3 | `ToMisakiIpa_三声連読_你也好` | `"你也好"` | `Assert.Contains("ni\u2197", result)` + 全体が3音節 | 3+3+3 連読 | -| 5.4 | `ToMisakiIpa_一変調_一个_二声矢印` | `"一个"` | `Assert.Contains("i\u2197", result)` (i↗) | 一+4声 → 2声変調 | -| 5.5 | `ToMisakiIpa_一変調_一天_四声矢印` | `"一天"` | `Assert.Contains("i\u2198", result)` (i↘) | 一+1声 → 4声変調 | -| 5.6 | `ToMisakiIpa_不変調_不要_二声矢印` | `"不要"` | `Assert.Contains("pu\u2197", result)` (pu↗) | 不+4声 → 2声変調 | -| 5.7 | `ToMisakiIpa_EnableToneSandhiFalse_你好_三声保持` | `"你好"`, sandhi=false | `Assert.Contains("ni\u2193", result)` (ni↓) | 変調無効で3声のまま | +| 5.1 | `ToMisakiIPA_三声連読_你好_前字が二声矢印` | `"你好"` | `Assert.Contains("ni\u2197", result)` (ni↗) | 3+3 → 2+3 変調 | +| 5.2 | `ToMisakiIPA_三声連読_你好_後字が三声矢印保持` | `"你好"` | `Assert.Contains("\u2193", result)` (↓) | 後字の三声矢印は残る | +| 5.3 | `ToMisakiIPA_三声連読_你也好` | `"你也好"` | `Assert.Contains("ni\u2197", result)` + 全体が3音節 | 3+3+3 連読 | +| 5.4 | `ToMisakiIPA_一変調_一个_二声矢印` | `"一个"` | `Assert.Contains("i\u2197", result)` (i↗) | 一+4声 → 2声変調 | +| 5.5 | `ToMisakiIPA_一変調_一天_四声矢印` | `"一天"` | `Assert.Contains("i\u2198", result)` (i↘) | 一+1声 → 4声変調 | +| 5.6 | `ToMisakiIPA_不変調_不要_二声矢印` | `"不要"` | `Assert.Contains("pu\u2197", result)` (pu↗) | 不+4声 → 2声変調 | +| 5.7 | `ToMisakiIPA_EnableToneSandhiFalse_你好_三声保持` | `"你好"`, sandhi=false | `Assert.Contains("ni\u2193", result)` (ni↓) | 変調無効で3声のまま | #### セクション 6: エッジケース | # | メソッド名 | 入力 | 期待Assert | 備考 | |---|----------|------|-----------|------| -| 6.1 | `ToMisakiIpa_null入力_空文字列` | `null` | `Assert.Equal("", result)` | null安全 | -| 6.2 | `ToMisakiIpa_空文字列_空文字列` | `""` | `Assert.Equal("", result)` | | -| 6.3 | `ToMisakiIpa_空白のみ_空文字列` | `" "` | `Assert.Equal("", result)` | | -| 6.4 | `ToMisakiIpa_タブ改行_空文字列` | `"\t\n"` | `Assert.Equal("", result)` | | -| 6.5 | `ToMisakiIpa_CJK句読点のみ_空文字列` | `",。!"` | `Assert.Equal("", result)` | | -| 6.6 | `ToMisakiIpa_数字のみ_数字パススルー` | `"123"` | `Assert.Contains("123", result)` | | -| 6.7 | `ToMisakiIpa_英数字混在_英数字パススルー` | `"OK了"` | `Assert.Contains("OK", result)` | | -| 6.8 | `ToMisakiIpa_非漢字混在_漢字部分のみ変換` | `"Hello你好"` | `Assert.Contains("Hello", result)` + `Assert.Contains("ni\u2197", result)` | | -| 6.9 | `ToMisakiIpa_er化音_儿_独立erが変換される` | `"儿"` (ér) | `Assert.NotEmpty(result)` + `Assert.Contains("\u2197", result)` (2声) | er韻母単独。Misakiでの表現は T01-T03 実装に準拠 | -| 6.10 | `ToMisakiIpa_ü母音_鱼_yが出力される` | `"鱼"` (yú) | `Assert.Contains("y", result)` | | -| 6.11 | `ToMisakiIpa_サロゲートペア_エラーなし` | `"\U00020000你好"` | `Assert.NotNull(result)` + `Assert.Contains("ni", result)` | | -| 6.12 | `ToMisakiIpa_絵文字混在_エラーなし` | `"好\U0001F600好"` | `Assert.NotEmpty(result)` | | -| 6.13 | `ToMisakiIpa_長文_全音節変換` | `"中华人民共和国"` (7字) | `Split(' ').Length == 7` | | +| 6.1 | `ToMisakiIPA_null入力_空文字列` | `null` | `Assert.Equal("", result)` | null安全 | +| 6.2 | `ToMisakiIPA_空文字列_空文字列` | `""` | `Assert.Equal("", result)` | | +| 6.3 | `ToMisakiIPA_空白のみ_空文字列` | `" "` | `Assert.Equal("", result)` | | +| 6.4 | `ToMisakiIPA_タブ改行_空文字列` | `"\t\n"` | `Assert.Equal("", result)` | | +| 6.5 | `ToMisakiIPA_CJK句読点のみ_空文字列` | `",。!"` | `Assert.Equal("", result)` | | +| 6.6 | `ToMisakiIPA_数字のみ_数字パススルー` | `"123"` | `Assert.Contains("123", result)` | | +| 6.7 | `ToMisakiIPA_英数字混在_英数字パススルー` | `"OK了"` | `Assert.Contains("OK", result)` | | +| 6.8 | `ToMisakiIPA_非漢字混在_漢字部分のみ変換` | `"Hello你好"` | `Assert.Contains("Hello", result)` + `Assert.Contains("ni\u2197", result)` | | +| 6.9 | `ToMisakiIPA_er化音_儿_独立erが変換される` | `"儿"` (ér) | `Assert.NotEmpty(result)` + `Assert.Contains("\u2197", result)` (2声) | er韻母単独。Misakiでの表現は T01-T03 実装に準拠 | +| 6.10 | `ToMisakiIPA_ü母音_鱼_yが出力される` | `"鱼"` (yú) | `Assert.Contains("y", result)` | | +| 6.11 | `ToMisakiIPA_サロゲートペア_エラーなし` | `"\U00020000你好"` | `Assert.NotNull(result)` + `Assert.Contains("ni", result)` | | +| 6.12 | `ToMisakiIPA_絵文字混在_エラーなし` | `"好\U0001F600好"` | `Assert.NotEmpty(result)` | | +| 6.13 | `ToMisakiIPA_長文_全音節変換` | `"中华人民共和国"` (7字) | `Split(' ').Length == 7` | | #### セクション 7: Issue #56 再現テスト @@ -224,10 +224,10 @@ Issue #56 の要望通り `"你好"` が Misaki 互換形式で出力される | # | メソッド名 | 入力 | 期待Assert | 備考 | |---|----------|------|-----------|------| -| 7.1 | `ToMisakiIpa_Issue56_你好_完全一致` | `"你好"` | `Assert.Equal("ni\u2197 xau\u032F\u2193", result)` | 三声連読後: ni↗ xau̯↓ | -| 7.2 | `ToMisakiIpa_Issue56_你好_声調矢印を含む` | `"你好"` | `Assert.Contains("\u2197", result)` + `Assert.Contains("\u2193", result)` | 2声矢印と3声矢印両方 | -| 7.3 | `ToMisakiIpa_Issue56_你好_IPA声調letterを含まない` | `"你好"` | `Assert.DoesNotContain("\u02E5", result)` ... 5種類 | Misakiは矢印のみ使用 | -| 7.4 | `ToMisakiIpa_Issue56_你好_非音節化符号を含む` | `"你好"` | `Assert.Contains("\u032F", result)` | au̯ の非音節化符号 | +| 7.1 | `ToMisakiIPA_Issue56_你好_完全一致` | `"你好"` | `Assert.Equal("ni\u2197 xau\u032F\u2193", result)` | 三声連読後: ni↗ xau̯↓ | +| 7.2 | `ToMisakiIPA_Issue56_你好_声調矢印を含む` | `"你好"` | `Assert.Contains("\u2197", result)` + `Assert.Contains("\u2193", result)` | 2声矢印と3声矢印両方 | +| 7.3 | `ToMisakiIPA_Issue56_你好_IPA声調letterを含まない` | `"你好"` | `Assert.DoesNotContain("\u02E5", result)` ... 5種類 | Misakiは矢印のみ使用 | +| 7.4 | `ToMisakiIPA_Issue56_你好_非音節化符号を含む` | `"你好"` | `Assert.Contains("\u032F", result)` | au̯ の非音節化符号 | **注**: 7.1 の完全一致テストは T01-T03 実装完了後に期待値を実装と合わせて調整すること。Misaki Python 実装との照合が望ましい(後述の「懸念事項」参照)。 @@ -235,38 +235,38 @@ Issue #56 の要望通り `"你好"` が Misaki 互換形式で出力される | # | メソッド名 | 入力 | 期待Assert | 備考 | |---|----------|------|-----------|------| -| 8.1 | `ToMisakiIpaBatch_複数テキスト_正しい件数` | `["你好", "世界", "中国"]` | `Assert.Equal(3, results.Count)` | | -| 8.2 | `ToMisakiIpaBatch_各結果が非空` | `["妈", "爸"]` | `Assert.NotEmpty(r)` 各要素 | | -| 8.3 | `ToMisakiIpaBatch_個別呼び出しと同一結果` | `["东", "元", "六"]` | `Assert.Equal(individual, batchResults[i])` | 個別呼び出しと一致 | -| 8.4 | `ToMisakiIpaBatch_IncludeTonesFalse_矢印なし` | `["妈", "麻"]`, `includeTones=false` | `Assert.DoesNotContain("\u2192", r)` ... 全矢印 | | -| 8.5 | `ToMisakiIpaBatch_空配列_空リスト` | `Array.Empty()` | `Assert.Empty(results)` | | -| 8.6 | `ToMisakiIpaBatch_混在入力_全要素が返る` | `["你好", "", null!, "世界"]` | 4要素、空/nullは空文字列 | | -| 8.7 | `ToMisakiIpaBatch_Null引数_ArgumentNullException` | `null` | `Assert.Throws` | | +| 8.1 | `ToMisakiIPABatch_複数テキスト_正しい件数` | `["你好", "世界", "中国"]` | `Assert.Equal(3, results.Count)` | | +| 8.2 | `ToMisakiIPABatch_各結果が非空` | `["妈", "爸"]` | `Assert.NotEmpty(r)` 各要素 | | +| 8.3 | `ToMisakiIPABatch_個別呼び出しと同一結果` | `["东", "元", "六"]` | `Assert.Equal(individual, batchResults[i])` | 個別呼び出しと一致 | +| 8.4 | `ToMisakiIPABatch_IncludeTonesFalse_矢印なし` | `["妈", "麻"]`, `includeTones=false` | `Assert.DoesNotContain("\u2192", r)` ... 全矢印 | | +| 8.5 | `ToMisakiIPABatch_空配列_空リスト` | `Array.Empty()` | `Assert.Empty(results)` | | +| 8.6 | `ToMisakiIPABatch_混在入力_全要素が返る` | `["你好", "", null!, "世界"]` | 4要素、空/nullは空文字列 | | +| 8.7 | `ToMisakiIPABatch_Null引数_ArgumentNullException` | `null` | `Assert.Throws` | | #### セクション 9: Dispose後の動作 | # | メソッド名 | 期待Assert | 備考 | |---|----------|-----------|------| -| 9.1 | `Dispose後_ToMisakiIpa_ObjectDisposedException` | `Assert.Throws` | | -| 9.2 | `Dispose後_ToMisakiIpa_WithTones_ObjectDisposedException` | 同上 | `includeTones` オーバーロード | -| 9.3 | `Dispose後_ToMisakiIpaBatch_ObjectDisposedException` | 同上 | | -| 9.4 | `Dispose後_ToMisakiIpaBatch_WithTones_ObjectDisposedException` | 同上 | | +| 9.1 | `Dispose後_ToMisakiIPA_ObjectDisposedException` | `Assert.Throws` | | +| 9.2 | `Dispose後_ToMisakiIPA_WithTones_ObjectDisposedException` | 同上 | `includeTones` オーバーロード | +| 9.3 | `Dispose後_ToMisakiIPABatch_ObjectDisposedException` | 同上 | | +| 9.4 | `Dispose後_ToMisakiIPABatch_WithTones_ObjectDisposedException` | 同上 | | #### セクション 10: 複数文字テキスト(音節区切り確認) | # | メソッド名 | 入力 | 期待Assert | 備考 | |---|----------|------|-----------|------| -| 10.1 | `ToMisakiIpa_複数漢字_スペース区切り` | `"中国"` | `Assert.Contains(" ", result)` | 音節間はスペース | -| 10.2 | `ToMisakiIpa_4文字_3スペース区切り` | `"你好世界"` | スペース数 == 3 | | -| 10.3 | `ToMisakiIpa_長文_文字数と音節数が一致` | `"我爱北京天安门"` | `Split(' ').Length == 7` | | +| 10.1 | `ToMisakiIPA_複数漢字_スペース区切り` | `"中国"` | `Assert.Contains(" ", result)` | 音節間はスペース | +| 10.2 | `ToMisakiIPA_4文字_3スペース区切り` | `"你好世界"` | スペース数 == 3 | | +| 10.3 | `ToMisakiIPA_長文_文字数と音節数が一致` | `"我爱北京天安门"` | `Split(' ').Length == 7` | | #### セクション 11: 標準IPA・piper-plus との比較 | # | メソッド名 | 入力 | 期待Assert | 備考 | |---|----------|------|-----------|------| -| 11.1 | `ToMisakiIpa_ToIPA出力と異なる` | `"妈"` | `Assert.NotEqual(standardIpa, misakiIpa)` | 声調記号体系が異なる | -| 11.2 | `ToMisakiIpa_ToPiperIPA出力と異なる` | `"几"` | `Assert.NotEqual(piperIpa, misakiIpa)` | j声母が異なる | -| 11.3 | `ToMisakiIpa_IncludeTonesFalse時_z声母だけがpiper-plusと異なる` | `"在"`, sandhi無関係 | piper: `ts`, misaki: `ʦ` | | +| 11.1 | `ToMisakiIPA_ToIPA出力と異なる` | `"妈"` | `Assert.NotEqual(standardIpa, misakiIpa)` | 声調記号体系が異なる | +| 11.2 | `ToMisakiIPA_ToPiperIPA出力と異なる` | `"几"` | `Assert.NotEqual(piperIpa, misakiIpa)` | j声母が異なる | +| 11.3 | `ToMisakiIPA_IncludeTonesFalse時_z声母だけがpiper-plusと異なる` | `"在"`, sandhi無関係 | piper: `ts`, misaki: `ʦ` | | #### セクション 12: 回帰確認(他APIへの影響なし) @@ -303,7 +303,7 @@ Issue #56 の要望通り `"你好"` が Misaki 互換形式で出力される | オプション | 方針 | |----------|------| | **A. `InternalsVisibleTo` 属性で `DotNetG2P.Tests` に公開** | 既存の `PinyinParser` などは同様のパターンを使用している前提で確認 | -| **B. E2Eテストのみで間接検証** | `ChineseG2PEngine.ToMisakiIpa()` を経由して検証する(現実的) | +| **B. E2Eテストのみで間接検証** | `ChineseG2PEngine.ToMisakiIPA()` を経由して検証する(現実的) | **採用**: **B 方針**(E2E 経由で検証)。理由: - 既存 `ChinesePiperIpaTests.cs` も `PinyinToPiperIpa` を直接テストせず `ToPiperIPA()` 経由で検証している @@ -312,7 +312,7 @@ Issue #56 の要望通り `"你好"` が Misaki 互換形式で出力される ### 4-2. エンドツーエンドテスト(E2E Tests) -`ChineseG2PEngine.ToMisakiIpa()` の全パイプラインを通すテスト。以下の処理を統合的に検証: +`ChineseG2PEngine.ToMisakiIPA()` の全パイプラインを通すテスト。以下の処理を統合的に検証: 1. 漢字入力 → フレーズ辞書/単字辞書によるピンイン解決 2. ピンイン → 声調変調(三声連読、一/不変調) @@ -402,7 +402,7 @@ Issue #56 の要望通り `"你好"` が Misaki 互換形式で出力される ### 5-4. T01-T03 の実装詳細に依存するテスト -**懸念**: テスト実装時点で `PinyinToMisaki` / `ToMisakiIpa()` の正確なシグネチャが確定していないと、テストコードがコンパイルエラーになる。 +**懸念**: テスト実装時点で `PinyinToMisaki` / `ToMisakiIPA()` の正確なシグネチャが確定していないと、テストコードがコンパイルエラーになる。 **対策**: - **T03 完了後に着手** (`depends_on: [T03]` として明示) @@ -450,9 +450,9 @@ Issue #56 の要望通り `"你好"` が Misaki 互換形式で出力される [InlineData("马", "ma\u2193", "声調3声")] [InlineData("骂", "ma\u2198", "声調4声")] [InlineData("吗", "ma", "軽声")] -public void ToMisakiIpa_声調網羅(string hanzi, string expected, string description) +public void ToMisakiIPA_声調網羅(string hanzi, string expected, string description) { - var result = _engine.ToMisakiIpa(hanzi); + var result = _engine.ToMisakiIPA(hanzi); Assert.Equal(expected, result); } ``` @@ -478,7 +478,7 @@ Portuguese/Spanish パッケージで採用されている方式: ### 6-3. Misaki との差分レポート自動生成ツール `tools/DotNetG2P.MisakiEval/` を新規作成し、以下を自動化: -1. 頻出漢字 1000 語リストを `ToMisakiIpa()` で変換 +1. 頻出漢字 1000 語リストを `ToMisakiIPA()` で変換 2. Misaki Python 実装と比較 3. 差分率(Phone Error Rate 相当)をレポート @@ -732,9 +732,9 @@ public class ChineseMisakiIpaTests : IDisposable [MemberData(nameof(ApicalVowelsCases))] [MemberData(nameof(SandhiCases))] [MemberData(nameof(Issue56Cases))] - public void ToMisakiIpa_DataDriven(MisakiTestCase c) + public void ToMisakiIPA_DataDriven(MisakiTestCase c) { - var result = _engine.ToMisakiIpa( + var result = _engine.ToMisakiIPA( c.Input, includeTones: c.Options.IncludeTones, enableToneSandhi: c.Options.EnableToneSandhi); @@ -776,7 +776,7 @@ public class ChineseMisakiIpaTests : IDisposable **C-1. 適用すべきシナリオ** 1. **複合シナリオの長文出力**: `"你好世界,我爱北京天安门。"` のような複数音節+句読点混在テキスト。完全一致を `Assert.Equal` で書くと Unicode エスケープで可読性が壊滅する -2. **Issue #56 の完全一致検証**: `"你好"` の 4 パターン (`ToMisakiIpa` / `includeTones=false` / `sandhi=false` / バッチAPI) をまとめて記録 +2. **Issue #56 の完全一致検証**: `"你好"` の 4 パターン (`ToMisakiIPA` / `includeTones=false` / `sandhi=false` / バッチAPI) をまとめて記録 3. **既存 API への回帰**: `ToIPA` / `ToPiperIPA` / `ToZhuyin` を含む 4 API × 頻出 100 漢字のマトリクスを 1 ファイルにまとめる **C-2. 実装例 (Verify.Xunit 28.x 系)** @@ -797,10 +797,10 @@ public class ChineseMisakiSnapshotTests : IDisposable { var result = new { - Default = _engine.ToMisakiIpa("你好"), - NoTones = _engine.ToMisakiIpa("你好", includeTones: false), - NoSandhi = _engine.ToMisakiIpa("你好", enableToneSandhi: false), - BatchDefault = _engine.ToMisakiIpaBatch(new[] { "你好" }).ToArray(), + Default = _engine.ToMisakiIPA("你好"), + NoTones = _engine.ToMisakiIPA("你好", includeTones: false), + NoSandhi = _engine.ToMisakiIPA("你好", enableToneSandhi: false), + BatchDefault = _engine.ToMisakiIPABatch(new[] { "你好" }).ToArray(), }; return Verify(result) .UseDirectory("Snapshots") @@ -816,7 +816,7 @@ public class ChineseMisakiSnapshotTests : IDisposable Hanzi = h, Standard = _engine.ToIPA(h), Piper = _engine.ToPiperIPA(h), - Misaki = _engine.ToMisakiIpa(h), + Misaki = _engine.ToMisakiIPA(h), Zhuyin = _engine.ToZhuyin(h), }).ToArray(); return Verify(matrix).UseDirectory("Snapshots"); @@ -937,7 +937,7 @@ issue56_variants default 4 4 0.0000 0.0000 0.00 true **E-2. 共通化する際のポイント** - TSV ヘッダを**全 API 共通**に揃える (`input\ttarget\texpected_equal\texpected_contains\toptions\tcategory\tnotes`) -- `target` カラムで `ToIPA` / `ToPiperIPA` / `ToMisakiIpa` / `ToZhuyin` を切り替え +- `target` カラムで `ToIPA` / `ToPiperIPA` / `ToMisakiIPA` / `ToZhuyin` を切り替え - ローダは `Dictionary>` で API 毎のアダプタを持つ - テストクラスは API 毎に分離 (`ChineseIpaTests` / `ChinesePiperIpaTests` / `ChineseMisakiIpaTests` / `ChineseZhuyinTests`) するが、**ローダとケース型は共通** にする @@ -983,7 +983,7 @@ issue56_variants default 4 4 0.0000 0.0000 0.00 true ### システム統合観点の追加レビュー -本節は、T04 のテスト設計を「DotNetG2P.Multilingual(多言語ファサード)/Unity UPM/NuGet/KokoroSharp」との連携前提でレビューした結果と、テスト統合観点での改善案を示す。上記 §テスト戦略の追加レビュー が「`ChineseG2PEngine` 単体のテスト品質」に主眼を置いていたのに対し、本節は **テスト対象の外** — 上位層・配布チャネル・下流ランタイム — からテストをどう検証するかに焦点を当てる。T04 の成果物は `ChineseG2PEngine.ToMisakiIpa` の正確性だけでなく、それが Multilingual 経由・Unity ランタイム上・KokoroSharp 統合で期待通り動くことを保証する必要がある。 +本節は、T04 のテスト設計を「DotNetG2P.Multilingual(多言語ファサード)/Unity UPM/NuGet/KokoroSharp」との連携前提でレビューした結果と、テスト統合観点での改善案を示す。上記 §テスト戦略の追加レビュー が「`ChineseG2PEngine` 単体のテスト品質」に主眼を置いていたのに対し、本節は **テスト対象の外** — 上位層・配布チャネル・下流ランタイム — からテストをどう検証するかに焦点を当てる。T04 の成果物は `ChineseG2PEngine.ToMisakiIPA` の正確性だけでなく、それが Multilingual 経由・Unity ランタイム上・KokoroSharp 統合で期待通り動くことを保証する必要がある。 #### A. Multilingual 層を経由したテスト(将来の布石) @@ -1011,16 +1011,16 @@ namespace DotNetG2P.Tests.Multilingual public void Dispose() => _engine.Dispose(); [Fact(Skip = "Mi3: Multilingual 層への Misaki 統合実装後に有効化")] - public void ToMisakiIpa_Chinese単独セグメント_Misaki出力() + public void ToMisakiIPA_Chinese単独セグメント_Misaki出力() { - // var result = _engine.ToMisakiIpa("你好"); + // var result = _engine.ToMisakiIPA("你好"); // Assert.Contains("\u2197", result); } [Fact(Skip = "Mi3: 中英混在 + フォールバック処理実装後")] - public void ToMisakiIpa_中英混在_Chinese部分のみMisaki_English部分はIPA() + public void ToMisakiIPA_中英混在_Chinese部分のみMisaki_English部分はIPA() { - // var result = _engine.ToMisakiIpa("你好 Hello 世界"); + // var result = _engine.ToMisakiIPA("你好 Hello 世界"); // Assert.Contains("ni\u2197", result); // Chinese 部分は Misaki // Assert.Contains("həloʊ", result); // English 部分は標準 IPA } @@ -1058,10 +1058,10 @@ public void ChineseG2PEngine_Preserve属性が付与されている() } [Fact] -public void ChineseG2PEngine_ToMisakiIpa_メソッドが公開されている() +public void ChineseG2PEngine_ToMisakiIPA_メソッドが公開されている() { var type = typeof(ChineseG2PEngine); - var method = type.GetMethod(nameof(ChineseG2PEngine.ToMisakiIpa), + var method = type.GetMethod(nameof(ChineseG2PEngine.ToMisakiIPA), new[] { typeof(string), typeof(bool) }); Assert.NotNull(method); Assert.True(method!.IsPublic); @@ -1087,14 +1087,14 @@ unity-il2cpp-smoke: targetPlatform: StandaloneLinux64 buildMethod: DotNetG2P.Tests.Unity.SmokeBuilder.Build # Packages/com.dotnetg2p.chinese を含む最小 Unity プロジェクトで - # engine.ToMisakiIpa("你好") を実行し、ランタイム例外が出ないことを確認 + # engine.ToMisakiIPA("你好") を実行し、ランタイム例外が出ないことを確認 ``` **判断**: **B-1 は T04 で実装必須**、B-2 は Mi3/Mi4 で検討。B-1 だけでも「`[Preserve]` 属性の外し忘れ」という最頻出の IL2CPP バグを検出できる。 #### C. KokoroSharp 統合テストの位置付け -T03 で追加される `ToMisakiIpa` API は KokoroSharp(または類似の Kokoro TTS C# 実装)から呼び出されることを前提としている。しかし KokoroSharp 自体への依存をテストプロジェクトに追加すると: +T03 で追加される `ToMisakiIPA` API は KokoroSharp(または類似の Kokoro TTS C# 実装)から呼び出されることを前提としている。しかし KokoroSharp 自体への依存をテストプロジェクトに追加すると: - NuGet 依存関係の複雑化 - KokoroSharp のバージョン互換性への配慮 @@ -1126,9 +1126,9 @@ public class KokoroSharpContractTests : IDisposable [InlineData("你好")] [InlineData("你好世界")] [InlineData("我爱北京天安门")] - public void ToMisakiIpa_KokoroSharp契約_制御文字を含まない(string input) + public void ToMisakiIPA_KokoroSharp契約_制御文字を含まない(string input) { - var result = _engine.ToMisakiIpa(input); + var result = _engine.ToMisakiIPA(input); Assert.NotNull(result); foreach (var ch in result) @@ -1141,18 +1141,18 @@ public class KokoroSharpContractTests : IDisposable } [Fact] - public void ToMisakiIpa_KokoroSharp契約_セグメント区切りはスペース() + public void ToMisakiIPA_KokoroSharp契約_セグメント区切りはスペース() { - var result = _engine.ToMisakiIpa("中国"); + var result = _engine.ToMisakiIPA("中国"); // KokoroSharp の tokenizer は " " (0x20) でセグメント分割する Assert.Contains(" ", result); } [Fact] - public void ToMisakiIpa_KokoroSharp契約_空入力で例外なし() + public void ToMisakiIPA_KokoroSharp契約_空入力で例外なし() { // KokoroSharp は空文字列を「無音」として扱う前提 - var result = _engine.ToMisakiIpa(""); + var result = _engine.ToMisakiIPA(""); Assert.Equal("", result); } } @@ -1176,7 +1176,7 @@ public void ChineseG2PEngine_デフォルトコンストラクタ_埋め込み { // このテストは NuGet 環境で動くが、UPM 環境でも同じロジックが使われる using var engine = new ChineseG2PEngine(); - var result = engine.ToMisakiIpa("你好"); + var result = engine.ToMisakiIPA("你好"); Assert.NotEmpty(result); // 埋め込み辞書が正しくロードされた証左として、 // 三声連読変調が適用されていることを確認 @@ -1201,7 +1201,7 @@ public void ChineseG2PEngine_Core参照なし_単独で動作する() #### E. 将来の他言語 Kokoro 互換追加に備えたテスト命名規則 -T03 §E で他言語の `ToMisakiIpa` 命名規則を統一することを推奨した。T04 のテストファイル命名もこれに揃えることで、Mi3/Mi4 で他言語の Misaki テストを追加する際のレビューコストを下げる: +T03 §E で他言語の `ToMisakiIPA` 命名規則を統一することを推奨した。T04 のテストファイル命名もこれに揃えることで、Mi3/Mi4 で他言語の Misaki テストを追加する際のレビューコストを下げる: **推奨テストクラス命名**: @@ -1222,7 +1222,7 @@ T03 §E で他言語の `ToMisakiIpa` 命名規則を統一することを推奨 - 配置: `tests/DotNetG2P.Tests/{言語名}G2P/` 配下(例: `tests/DotNetG2P.Tests/ChineseG2P/ChineseMisakiIpaTests.cs`) - TSV データ配置: `tests/TestData/{言語名}G2P/misaki_*.tsv` - コンストラクタ/Dispose パターン: 全言語で統一(`IDisposable` + `_engine` フィールド) -- テストメソッド命名: 既存の日本語命名ルール(例: `ToMisakiIpa_第1声_矢印右向き`)を他言語にも適用 +- テストメソッド命名: 既存の日本語命名ルール(例: `ToMisakiIPA_第1声_矢印右向き`)を他言語にも適用 **契約テストの共通基底クラス**(Mi3 以降): @@ -1232,7 +1232,7 @@ public abstract class KokoroSharpContractTestsBase : IDisposable where TEngine : IDisposable { protected abstract TEngine CreateEngine(); - protected abstract string ConvertToMisakiIpa(TEngine engine, string text); + protected abstract string ConvertToMisakiIPA(TEngine engine, string text); [Theory] [MemberData(nameof(InputSamples))] @@ -1283,18 +1283,18 @@ T04 完了時点で以下を T05 の作業者に引き継ぐこと: - サロゲートペア・絵文字混在時の挙動 4. **パフォーマンス指標** - - `ToMisakiIpa` の実行時間が `ToIPA` / `ToPiperIPA` と同等であることの確認結果 + - `ToMisakiIPA` の実行時間が `ToIPA` / `ToPiperIPA` と同等であることの確認結果 - Mi3 のパフォーマンステスト計画への引き継ぎ 5. **Issue #56 再現テストの結果** - - `"你好"` の実際の出力(複数パターン: `ToMisakiIpa` / `ToMisakiIpa(text, includeTones:false)` / `enableToneSandhi:false`) + - `"你好"` の実際の出力(複数パターン: `ToMisakiIPA` / `ToMisakiIPA(text, includeTones:false)` / `enableToneSandhi:false`) - Issue 本文の期待値 `ni↓xau̯↓` との一致状況 - Issue へのコメント下書きを T05 のドキュメント更新タスクに含めること 6. **README.md に記載すべき使用例** ```csharp using var engine = new ChineseG2PEngine(); - var result = engine.ToMisakiIpa("你好"); + var result = engine.ToMisakiIPA("你好"); // 出力例: "ni↗ xau̯↓" ``` @@ -1323,7 +1323,7 @@ T05 の作業者は以下を確認してから作業開始すること: | 項目 | 値 | |------|-----| | **マイルストーン** | **Mi2** — ChineseG2PEngine API統合 + テスト | -| **依存** | **T03** — `PinyinToMisaki.cs` + `ChineseG2PEngine.ToMisakiIpa()` 実装完了 | +| **依存** | **T03** — `PinyinToMisaki.cs` + `ChineseG2PEngine.ToMisakiIPA()` 実装完了 | | **後続** | **T05** — ドキュメント更新(README.md, CLAUDE.md, 設計ドキュメント、Issue#56 コメント) | | **関連 Issue** | [#56 — How can i make result similar like misaki does?](https://github.com/ayutaz/dot-net-g2p/issues/56) | | **関連ドキュメント** | [docs/guides/misaki-compatible-chinese.md](../guides/misaki-compatible-chinese.md) | diff --git a/docs/tickets/T05-documentation-qa.md b/docs/tickets/T05-documentation-qa.md index 1e85566..5f81fed 100644 --- a/docs/tickets/T05-documentation-qa.md +++ b/docs/tickets/T05-documentation-qa.md @@ -2,7 +2,7 @@ ticket: T05 title: ドキュメント更新・品質保証 milestone: Mi3 -status: 未着手 +status: 完了 depends_on: [T04] blocks: [T06] --- @@ -11,13 +11,13 @@ blocks: [T06] ## 1. タスク目的とゴール -T01-T04 で実装・テスト完了した Misaki 互換中国語 G2P 出力(`ToMisakiIpa()` / `ToMisakiIpaBatch()`)について、ドキュメント整備と品質保証を行い、利用者が Misaki 互換出力を正しく使えるようにする。 +T01-T04 で実装・テスト完了した Misaki 互換中国語 G2P 出力(`ToMisakiIPA()` / `ToMisakiIPABatch()`)について、ドキュメント整備と品質保証を行い、利用者が Misaki 互換出力を正しく使えるようにする。 **ゴール:** -- 利用者が README.md を読むだけで `ToMisakiIpa()` の使い方と出力形式を理解できる +- 利用者が README.md を読むだけで `ToMisakiIPA()` の使い方と出力形式を理解できる - Kokoro TTS (KokoroSharp) との連携に必要な情報がドキュメントに揃っている -- パフォーマンス面で `ToMisakiIpa` が `ToIPA` と同等の速度であることが確認されている +- パフォーマンス面で `ToMisakiIPA` が `ToIPA` と同等の速度であることが確認されている - CLAUDE.md の進捗テーブルが最新状態に更新されている - 設計ドキュメント・マイルストーンドキュメントの完了状態が反映されている @@ -41,7 +41,7 @@ zhEngine.ToPinyin("你好世界"); // => "ní hǎo shì jiè" ```csharp // 中国語G2P(Misaki互換IPA — Kokoro TTS向け) -zhEngine.ToMisakiIpa("你好世界"); // => "ni↗xau̯↓ʂʐ̩↘ʨiɛ↘" +zhEngine.ToMisakiIPA("你好世界"); // => "ni↗xau̯↓ʂʐ̩↘ʨiɛ↘" ``` #### 2.1.2 特徴セクション(L78付近)の中国語G2P説明への追記 @@ -62,18 +62,18 @@ zhEngine.ToMisakiIpa("你好世界"); // => "ni↗xau̯↓ʂʐ̩↘ʨiɛ↘" ```csharp // Misaki 互換 IPA(Kokoro TTS向け) -string misakiIpa = zhEngine.ToMisakiIpa("你好"); +string misakiIpa = zhEngine.ToMisakiIPA("你好"); // => "ni↗xau̯↓" // 声調記号: → (1声), ↗ (2声), ↓ (3声), ↘ (4声) // 声母: j→ʨ, q→ʨʰ, z→ʦ, c→ʦʰ // 韻母: ai→ai̯, ao→au̯, ei→ei̯, ou→ou̯ (非音節化符号) // 声調なし Misaki 互換 IPA -string misakiNoTone = zhEngine.ToMisakiIpa("你好", includeTones: false); +string misakiNoTone = zhEngine.ToMisakiIPA("你好", includeTones: false); // => "nixau̯" // バッチ変換 -string[] misakiBatch = zhEngine.ToMisakiIpaBatch(new[] { "你好", "世界" }); +string[] misakiBatch = zhEngine.ToMisakiIPABatch(new[] { "你好", "世界" }); ``` #### 2.1.4 API リファレンステーブル(L481-512付近、ChineseG2PEngine セクション)への追記 @@ -81,10 +81,10 @@ string[] misakiBatch = zhEngine.ToMisakiIpaBatch(new[] { "你好", "世界" }); 既存テーブルの `ToIpaWithProsodyBatch(texts, includeTones)` 行の後に以下の行を追加: ``` -| `ToMisakiIpa(text)` | `string` | Misaki互換IPA文字列(矢印声調記号付き) | -| `ToMisakiIpa(text, includeTones)` | `string` | 声調制御付きMisaki互換IPA | -| `ToMisakiIpaBatch(texts)` | `string[]` | バッチMisaki互換IPA変換 | -| `ToMisakiIpaBatch(texts, includeTones)` | `string[]` | バッチMisaki互換IPA変換(声調制御) | +| `ToMisakiIPA(text)` | `string` | Misaki互換IPA文字列(矢印声調記号付き) | +| `ToMisakiIPA(text, includeTones)` | `string` | 声調制御付きMisaki互換IPA | +| `ToMisakiIPABatch(texts)` | `string[]` | バッチMisaki互換IPA変換 | +| `ToMisakiIPABatch(texts, includeTones)` | `string[]` | バッチMisaki互換IPA変換(声調制御) | ``` ### 2.2 CLAUDE.md の更新 @@ -155,8 +155,8 @@ string[] misakiBatch = zhEngine.ToMisakiIpaBatch(new[] { "你好", "世界" }); ### 4.1 ドキュメント正確性チェック - [ ] README.md のコードサンプルが実際にコンパイル・実行できること - - `ToMisakiIpa("你好")` のコメントに書かれた出力例が実際の出力と一致すること - - `ToMisakiIpa("你好世界")` のコメントに書かれた出力例が実際の出力と一致すること + - `ToMisakiIPA("你好")` のコメントに書かれた出力例が実際の出力と一致すること + - `ToMisakiIPA("你好世界")` のコメントに書かれた出力例が実際の出力と一致すること - バッチ API のサンプルが正しいこと - [ ] API リファレンステーブルのメソッドシグネチャが実装と一致すること - 戻り値型(`string` / `string[]`)が正しいこと @@ -171,9 +171,9 @@ string[] misakiBatch = zhEngine.ToMisakiIpaBatch(new[] { "你好", "世界" }); ### 4.3 パフォーマンステスト -- [ ] `ToMisakiIpa` が `ToIPA` と同等の処理速度であることを確認 +- [ ] `ToMisakiIPA` が `ToIPA` と同等の処理速度であることを確認 - 測定方法: 同一テキストセット(100文以上)を各メソッドで1000回変換し、平均処理時間を比較 - - 許容範囲: `ToMisakiIpa` の処理時間が `ToIPA` の 1.2 倍以内 + - 許容範囲: `ToMisakiIPA` の処理時間が `ToIPA` の 1.2 倍以内 - 根拠: `PinyinToMisaki` は `PinyinToIpa` と同じく静的辞書参照のみでアルゴリズム計算量は同等。声調マッピングが配列インデックスアクセスのため差が出にくい - [ ] テストクラス `ChineseMisakiPerformanceTests.cs` の作成(任意) - BenchmarkDotNet または Stopwatch による簡易ベンチマーク @@ -212,7 +212,7 @@ string[] misakiBatch = zhEngine.ToMisakiIpaBatch(new[] { "你好", "世界" }); ### 5.3 レビュー項目 -- [ ] README.md のコードサンプル内のコメント出力例が、実際の `ToMisakiIpa()` 出力と完全一致すること +- [ ] README.md のコードサンプル内のコメント出力例が、実際の `ToMisakiIPA()` 出力と完全一致すること - [ ] Unicode 文字(矢印声調記号 → ↗ ↓ ↘、非音節化符号 U+032F)がドキュメント内で正しくレンダリングされること(GitHub Markdown ビューアで確認) - [ ] CLAUDE.md の更新が他のセクション(プロジェクト概要、技術スタック等)と整合していること - [ ] API リファレンステーブルの追加行が既存行のフォーマット(パイプ区切り、等幅フォント等)と一致していること @@ -275,14 +275,14 @@ string[] misakiBatch = zhEngine.ToMisakiIpaBatch(new[] { "你好", "世界" }); - `` — 実行可能な C# コードブロック(README/DocFX 共用) - `` / `` — API テーブル生成用 - `tools/GenerateReadmeSnippets` というビルドタスクを新設し、XMLDoc の `` タグから C# コードブロックを抽出して `docs/_generated/snippets/` へ出力 -- README.md / README_EN.md 内では `` のようなプレースホルダを使い、ビルド時に `_generated/snippets/` の内容へ置換する(Markdown インクルード方式) +- README.md / README_EN.md 内では `` のようなプレースホルダを使い、ビルド時に `_generated/snippets/` の内容へ置換する(Markdown インクルード方式) - これにより「コード例の変更は XMLDoc の修正だけで全ドキュメントに波及」する構造になり、T05 第 4 節で懸念している「コメント出力例と実装の乖離」を根本解決できる **B-2. API テーブルの自動生成** - `tools/GenerateApiTable` ビルドタスクを新設し、DocFX の `metadata` ステージで生成される中間 YAML (`api/*.yml`) をパースして、言語パッケージごとに README 用の Markdown テーブル (`docs/_generated/api-tables/{lang}.md`) を出力 - README.md の「API リファレンス」節は手書きをやめ、`` 形式の include プレースホルダにする -- 現在 T05 第 2.1.4 節で手作業追加している `ToMisakiIpa(text)` 等の行が自動追記されるようになる +- 現在 T05 第 2.1.4 節で手作業追加している `ToMisakiIPA(text)` 等の行が自動追記されるようになる **B-3. 進捗テーブルの CLAUDE.md ↔ README.md 同期** @@ -310,7 +310,7 @@ string[] misakiBatch = zhEngine.ToMisakiIpaBatch(new[] { "你好", "世界" }); - `tools/TranslateDocs` というユーティリティを作成し、以下の翻訳戦略を段階的に検討: - **段階 1: DeepL API (Pro)** — 技術用語の精度が高い。月 50 万文字まで有料プラン。README 規模(3 言語 × 600 行)なら月 \$10 程度でカバー - - **段階 2: OpenAI API / Anthropic API** — プロンプトで「音声学専門用語は保持、`ToMisakiIpa` 等のコードは翻訳しない」と制御可能。コスト \$5〜20/月 + - **段階 2: OpenAI API / Anthropic API** — プロンプトで「音声学専門用語は保持、`ToMisakiIPA` 等のコードは翻訳しない」と制御可能。コスト \$5〜20/月 - **段階 3: ローカル LLM (Qwen2.5-7B 等)** — コスト 0 だが品質は要評価 - 翻訳対象は `` / `` マーカーで区切り、コードブロック・表・リンクはスキップ - CI で翻訳差分を PR として自動作成(`github-actions[bot]` が `docs: auto-translate README to EN/ZH` PR を作成) @@ -331,7 +331,7 @@ T05 第 6.1 節の `[Fact]` の DisplayName / InlineData からサンプル抽 - `tests/DotNetG2P.Tests/` 配下のテストに `[Trait("DocSample", "chinese.misaki-ipa")]` のようなタグを付与 - `tools/ExtractTestSamples` ツールが MSTest の test assembly を Reflection で読み、`DocSample` トレイト付きテストから: - `[InlineData("你好", "ni↗xau̯↓")]` などの引数 → `input = "你好", expected = "ni↗xau̯↓"` の形で抽出 - - テストメソッド本体の 1 行分を「使用例」として抽出(`engine.ToMisakiIpa("你好")` の行) + - テストメソッド本体の 1 行分を「使用例」として抽出(`engine.ToMisakiIPA("你好")` の行) - 抽出結果を `docs/_generated/examples/chinese.misaki-ipa.md` に出力し、README と DocFX に include する **D-2. DocFX `` タグとの統合** @@ -486,7 +486,7 @@ T06(Issue #56 フォローアップ)に伝えるべき情報: - T05 完了後、Issue #56 に実装完了の報告コメントを投稿すること - コメントには以下を含める: - - `ToMisakiIpa()` の使用例コード + - `ToMisakiIPA()` の使用例コード - NuGet パッケージバージョン(Mi3 リリース後のバージョン番号) - 既知の制限事項(Legacy パスのみ対応、Multilingual 層未統合等) @@ -499,12 +499,12 @@ T06(Issue #56 フォローアップ)に伝えるべき情報: ### 7.3 Multilingual 層への統合検討 - T01-T04 の実装では `DotNetG2P.Multilingual` への統合は見送っている -- `MultilingualG2PEngine` に `ToMisakiIpa()` を追加するかは T06 で検討すること +- `MultilingualG2PEngine` に `ToMisakiIPA()` を追加するかは T06 で検討すること - 追加する場合、`MultilingualG2POptions` に Misaki 出力モードの設定が必要になる可能性がある ### 7.4 パフォーマンステスト結果の引き継ぎ -- T05 で実施したパフォーマンステスト結果(ToMisakiIpa vs ToIPA の処理時間比較)を T06 に引き継ぐ +- T05 で実施したパフォーマンステスト結果(ToMisakiIPA vs ToIPA の処理時間比較)を T06 に引き継ぐ - 性能劣化が見られた場合はその原因と改善案を記録しておくこと ### 7.5 Misaki 仕様追従の監視 diff --git a/docs/tickets/T06-release-followup.md b/docs/tickets/T06-release-followup.md index b12eb9b..9f9d6b0 100644 --- a/docs/tickets/T06-release-followup.md +++ b/docs/tickets/T06-release-followup.md @@ -2,7 +2,7 @@ ticket: T06 title: Issue#56 フォローアップ・リリース準備 milestone: Mi3 -status: 未着手 +status: 完了 depends_on: [T05] blocks: [] --- From 97571830baf1662be8557b5c5ef714770b3c3615 Mon Sep 17 00:00:00 2001 From: yousan Date: Mon, 13 Apr 2026 21:03:54 +0900 Subject: [PATCH 17/21] =?UTF-8?q?docs:=20PR=20#59=20Copilot=20=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=20(8=E4=BB=B6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - misaki-compatible-chinese.md: U+032F 記述除去、バッチ戻り値型修正、zh/chꭧ合字の記述更新 - misaki-milestones.md: API名 ToMisakiIPA 統一、出力例の U+032F 除去 - T05-documentation-qa.md: 出力例修正、バッチ戻り値型 string[]→IReadOnlyList --- docs/guides/misaki-compatible-chinese.md | 46 ++++++++++++------------ docs/guides/misaki-milestones.md | 26 +++++++------- docs/tickets/T05-documentation-qa.md | 8 ++--- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/docs/guides/misaki-compatible-chinese.md b/docs/guides/misaki-compatible-chinese.md index 000edb3..535d248 100644 --- a/docs/guides/misaki-compatible-chinese.md +++ b/docs/guides/misaki-compatible-chinese.md @@ -14,11 +14,11 @@ DotNetG2P.ChineseにMisaki互換出力モードを追加することで、C#/Uni | 項目 | Misaki (Legacy) | DotNetG2P 現行 | |------|----------------|----------------| -| 出力例 | `ni↓xau̯↓` | `ni˧˥ xaʊ˨˩˦` | +| 出力例 | `ni↓xau↓` | `ni˧˥ xaʊ˨˩˦` | | 声調記号 | 矢印 (`→` `↗` `↓` `↘`) | IPA tone letters (`˥˥` `˧˥` `˨˩˦` `˥˩`) | | 音節区切り | スペース (語間) | スペース (音節間) | | 声母 j/q | `ʨ` / `ʨʰ` | `tɕ` / `tɕʰ` | -| 二重母音 | `ai̯` `au̯` `ei̯` `ou̯` (非音節化符号) | `aɪ` `aʊ` `eɪ` `oʊ` (別字母) | +| 二重母音 | `ai` `au` `ei` `ou` (非音節化符号は出力に含まれない) | `aɪ` `aʊ` `eɪ` `oʊ` (別字母) | | zh/ch/sh+i | `ɻ̩` / `ʐ̩` | `ɻ̩` | | z/c/s+i | `ɹ̩` / `z̩` | `ɹ̩` | @@ -44,13 +44,13 @@ DotNetG2P.ChineseにMisaki互換出力モードを追加することで、C#/Uni | 韻母 | DotNetG2P | Misaki | |------|-----------|--------| -| ai | `aɪ` (U+026A) | `ai̯` (i + U+032F) | -| ei | `eɪ` | `ei̯` | -| ao | `aʊ` (U+028A) | `au̯` (u + U+032F) | -| ou | `oʊ` | `ou̯` | -| uai | `uaɪ` | `uai̯` | -| ui | `ueɪ` | `uei̯` | -| iu | `ioʊ` | `iou̯` | +| ai | `aɪ` (U+026A) | `ai` (非音節化符号は出力に含まれない) | +| ei | `eɪ` | `ei` | +| ao | `aʊ` (U+028A) | `au` | +| ou | `oʊ` | `ou` | +| uai | `uaɪ` | `uai` | +| ui | `ueɪ` | `uei` | +| iu | `ioʊ` | `iou` | ## 実装方式 @@ -140,15 +140,15 @@ private static readonly Dictionary s_initialIpa = new() ### 韻母マッピング (二重母音の差異) ```csharp -// 主な差異: ɪ→i̯, ʊ→u̯ (非音節化符号 U+032F 付き) -[Final.Ai] = "ai\u032F", // aɪ → ai̯ -[Final.Ei] = "ei\u032F", // eɪ → ei̯ -[Final.Ao] = "au\u032F", // aʊ → au̯ -[Final.Ou] = "ou\u032F", // oʊ → ou̯ -[Final.Iao] = "iau\u032F", // iaʊ → iau̯ -[Final.Iu] = "iou\u032F", // ioʊ → iou̯ -[Final.Uai] = "uai\u032F", // uaɪ → uai̯ -[Final.Ui] = "uei\u032F", // ueɪ → uei̯ +// 主な差異: ɪ→i, ʊ→u (非音節化符号 U+032F は出力に含まれない) +[Final.Ai] = "ai", // aɪ → ai +[Final.Ei] = "ei", // eɪ → ei +[Final.Ao] = "au", // aʊ → au +[Final.Ou] = "ou", // oʊ → ou +[Final.Iao] = "iau", // iaʊ → iau +[Final.Iu] = "iou", // ioʊ → iou +[Final.Uai] = "uai", // uaɪ → uai +[Final.Ui] = "uei", // ueɪ → uei ``` ### 声調マッピング @@ -174,8 +174,8 @@ public string ToMisakiIPA(string text) public string ToMisakiIPA(string text, bool includeTones) // バッチ出力 -public string[] ToMisakiIPABatch(string[] texts) -public string[] ToMisakiIPABatch(string[] texts, bool includeTones) +public IReadOnlyList ToMisakiIPABatch(string[] texts) +public IReadOnlyList ToMisakiIPABatch(string[] texts, bool includeTones) ``` ## テスト方針 @@ -187,15 +187,15 @@ public string[] ToMisakiIPABatch(string[] texts, bool includeTones) 3. **韻母マッピング**: 二重母音の非音節化符号が正しいこと 4. **声調変調**: ToneSandhi の結果が Misaki 出力にも反映されること 5. **エッジケース**: 軽声、er化、句読点、空文字列等 -6. **Misaki 出力例との比較**: issue #56 の `ni↓xau̯↓` 等 +6. **Misaki 出力例との比較**: issue #56 の `ni↓xau↓` 等 ## 備考 - Misaki には Legacy パス (IPA+矢印) と v1.1 パス (注音符号) の2つが存在するが、Kokoro-82M で使用されるのは Legacy パスのみ。本対応は Legacy パスを対象とする -- Misaki が `ꭧ` (U+AB67) を zh/ch の子音IPAに使用する件は、Kokoro vocab に含まれない可能性があるため初期対応では見送り、必要に応じて追加する +- Misaki が `ꭧ` (U+AB67) を zh/ch の子音IPAに使用する件は、現行実装で ꭧ (U+AB67) / ꭧʰ を使用している - Multilingual 層への統合は将来の追加作業とする ### Phase 1-R 実装知見 - Misaki legacy は 3-3 tone sandhi (三声連読変調) を適用しない。DotNetG2P では `EnableToneSandhi` オプションで制御可能。Misaki legacy と完全一致させるには `EnableToneSandhi = false` でエンジンを初期化する -- U+032F (COMBINING INVERTED BREVE BELOW, 非音節化符号) は Misaki テンプレート側で事前除去されるため、実際の出力には含まれない。DotNetG2P の `ToMisakiIPA()` はマッピングテーブルに U+032F を含むが、Kokoro TTS に渡す前にテンプレート処理で除去される想定 +- U+032F (COMBINING INVERTED BREVE BELOW, 非音節化符号) は Misaki テンプレート側で事前除去されるため、`ToMisakiIPA()` の出力には含まれない diff --git a/docs/guides/misaki-milestones.md b/docs/guides/misaki-milestones.md index b17d704..edc26d0 100644 --- a/docs/guides/misaki-milestones.md +++ b/docs/guides/misaki-milestones.md @@ -36,8 +36,8 @@ DotNetG2P.Chinese に Misaki 互換出力モードを追加する。全3マイ - `z/c` → `ʦ/ʦʰ` (DotNetG2Pの `ts/tsʰ` から変更) - 他は標準IPA と同一 - [x] 韻母マッピングテーブル `s_finalIpa` (32エントリ) - - 二重母音: `ɪ` → `i̯` (i + U+032F)、`ʊ` → `u̯` (u + U+032F) - - 例: `aɪ` → `ai̯`、`aʊ` → `au̯`、`eɪ` → `ei̯`、`oʊ` → `ou̯` + - 二重母音: `ɪ` → `i`、`ʊ` → `u` (非音節化符号 U+032F は出力に含まれない) + - 例: `aɪ` → `ai`、`aʊ` → `au`、`eɪ` → `ei`、`oʊ` → `ou` - [x] 声調マッピング `s_toneArrows` (5エントリ) - 1声 → `→`、2声 → `↗`、3声 → `↓`、4声 → `↘`、軽声 → 空 - [x] `Convert(string pinyin)` / `Convert(string pinyin, bool includeTones)` メソッド @@ -49,9 +49,9 @@ DotNetG2P.Chinese に Misaki 互換出力モードを追加する。全3マイ ### 完了条件 - `PinyinToMisaki.Convert("mā")` → `"ma→"` が返ること -- `PinyinToMisaki.Convert("hǎo")` → `"xau̯↓"` が返ること +- `PinyinToMisaki.Convert("hǎo")` → `"xau↓"` が返ること - `PinyinToMisaki.Convert("jī")` → `"ʨi→"` が返ること (声母差異) -- `PinyinToMisaki.Convert("māi")` → `"mai̯→"` が返ること (韻母差異) +- `PinyinToMisaki.Convert("māi")` → `"mai→"` が返ること (韻母差異) - ビルドが通ること (`dotnet build`) --- @@ -69,10 +69,10 @@ DotNetG2P.Chinese に Misaki 互換出力モードを追加する。全3マイ ### 実装内容 — API -- [x] `ToMisakiIpa(string text)` — Misaki互換IPA文字列を返す -- [x] `ToMisakiIpa(string text, bool includeTones)` — 声調有無指定オーバーロード -- [x] `ToMisakiIpaBatch(string[] texts)` — バッチ変換 -- [x] `ToMisakiIpaBatch(string[] texts, bool includeTones)` — バッチ変換 (声調有無指定) +- [x] `ToMisakiIPA(string text)` — Misaki互換IPA文字列を返す +- [x] `ToMisakiIPA(string text, bool includeTones)` — 声調有無指定オーバーロード +- [x] `ToMisakiIPABatch(string[] texts)` — バッチ変換 +- [x] `ToMisakiIPABatch(string[] texts, bool includeTones)` — バッチ変換 (声調有無指定) - [x] 内部実装: `RunPipeline(text, p => PinyinToMisaki.Convert(p, includeTones))` パターン ### 実装内容 — テスト @@ -82,9 +82,9 @@ DotNetG2P.Chinese に Misaki 互換出力モードを追加する。全3マイ - [x] **声母テスト**: Misaki固有の声母マッピング - `j/q` → `ʨ/ʨʰ`、`z/c` → `ʦ/ʦʰ` - [x] **韻母テスト**: 二重母音の非音節化符号 - - `ai/ei/ao/ou` → `ai̯/ei̯/au̯/ou̯` + - `ai/ei/ao/ou` → `ai/ei/au/ou` - [x] **声調変調テスト**: ToneSandhi結果がMisaki出力にも反映 - - 三声連読: `你好` → 3+3 → 2+3 → `ni↗xau̯↓` + - 三声連読: `你好` → 3+3 → 2+3 → `ni↗xau↓` - 「一」変調: `一个` → `i↘kɤ↘` (一 + 4声 → 2声に変調) - [x] **エッジケーステスト** - 空文字列 → 空文字列 @@ -98,7 +98,7 @@ DotNetG2P.Chinese に Misaki 互換出力モードを追加する。全3マイ - `dotnet test` で ChineseMisakiIpaTests 全件パス - 既存テスト (936件) に回帰なし -- `engine.ToMisakiIpa("你好")` が Misaki と同等の出力を返すこと +- `engine.ToMisakiIPA("你好")` が Misaki と同等の出力を返すこと --- @@ -119,13 +119,13 @@ DotNetG2P.Chinese に Misaki 互換出力モードを追加する。全3マイ - [x] **README.md 更新** - 中国語セクションに Misaki 互換出力の使用例を追加 - - `ToMisakiIpa()` の API 説明 + - `ToMisakiIPA()` の API 説明 - Kokoro TTS との連携例 - [x] **CLAUDE.md 更新** - 中国語パッケージの備考に「Misaki互換出力対応」を追記 - [x] **品質保証** - Misaki の Python 実装との出力比較テスト (可能な範囲で) - - パフォーマンステスト: `ToMisakiIpa` が `ToIPA` と同等の速度であること + - パフォーマンステスト: `ToMisakiIPA` が `ToIPA` と同等の速度であること - [x] **Issue #56 へのフォローアップコメント** - 実装完了の報告 - 使用例コード提示 diff --git a/docs/tickets/T05-documentation-qa.md b/docs/tickets/T05-documentation-qa.md index 5f81fed..e9993d2 100644 --- a/docs/tickets/T05-documentation-qa.md +++ b/docs/tickets/T05-documentation-qa.md @@ -41,7 +41,7 @@ zhEngine.ToPinyin("你好世界"); // => "ní hǎo shì jiè" ```csharp // 中国語G2P(Misaki互換IPA — Kokoro TTS向け) -zhEngine.ToMisakiIPA("你好世界"); // => "ni↗xau̯↓ʂʐ̩↘ʨiɛ↘" +zhEngine.ToMisakiIPA("你好世界"); // Misaki互換IPA文字列(矢印声調記号付き、スペース区切り) ``` #### 2.1.2 特徴セクション(L78付近)の中国語G2P説明への追記 @@ -73,7 +73,7 @@ string misakiNoTone = zhEngine.ToMisakiIPA("你好", includeTones: false); // => "nixau̯" // バッチ変換 -string[] misakiBatch = zhEngine.ToMisakiIPABatch(new[] { "你好", "世界" }); +IReadOnlyList misakiBatch = zhEngine.ToMisakiIPABatch(new[] { "你好", "世界" }); ``` #### 2.1.4 API リファレンステーブル(L481-512付近、ChineseG2PEngine セクション)への追記 @@ -83,8 +83,8 @@ string[] misakiBatch = zhEngine.ToMisakiIPABatch(new[] { "你好", "世界" }); ``` | `ToMisakiIPA(text)` | `string` | Misaki互換IPA文字列(矢印声調記号付き) | | `ToMisakiIPA(text, includeTones)` | `string` | 声調制御付きMisaki互換IPA | -| `ToMisakiIPABatch(texts)` | `string[]` | バッチMisaki互換IPA変換 | -| `ToMisakiIPABatch(texts, includeTones)` | `string[]` | バッチMisaki互換IPA変換(声調制御) | +| `ToMisakiIPABatch(texts)` | `IReadOnlyList` | バッチMisaki互換IPA変換 | +| `ToMisakiIPABatch(texts, includeTones)` | `IReadOnlyList` | バッチMisaki互換IPA変換(声調制御) | ``` ### 2.2 CLAUDE.md の更新 From 057764b78f830193df4f7c2ebe4afdec7a82a297 Mon Sep 17 00:00:00 2001 From: yousan Date: Mon, 13 Apr 2026 21:13:59 +0900 Subject: [PATCH 18/21] =?UTF-8?q?fix:=20PinyinToMisaki.cs.meta=20=E6=AC=A0?= =?UTF-8?q?=E8=90=BD=E3=82=92=E4=BF=AE=E6=AD=A3=20(CI=20Unity=20.meta=20?= =?UTF-8?q?=E3=83=81=E3=82=A7=E3=83=83=E3=82=AF=E5=AF=BE=E5=BF=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Conversion/PinyinToMisaki.cs.meta | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs.meta diff --git a/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs.meta b/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs.meta new file mode 100644 index 0000000..dcd35eb --- /dev/null +++ b/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d472447692340de8a232c3b80563b2d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 9ea120b2120fdcf4b7f9c4592b9dfda1c0daa50a Mon Sep 17 00:00:00 2001 From: yousan Date: Mon, 13 Apr 2026 21:38:34 +0900 Subject: [PATCH 19/21] =?UTF-8?q?fix:=20PR=20#59=20=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E4=BF=AE=E6=AD=A3=20?= =?UTF-8?q?=E2=80=94=20Y/W=20compound=20miss=20=E3=82=AC=E3=83=BC=E3=83=89?= =?UTF-8?q?=20+=20=E3=83=86=E3=82=B9=E3=83=88=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PinyinToMisaki.cs: Y/W が compound テーブルにない場合 string.Empty を返すガード追加 - PinyinToMisakiMappingTests.cs: Assert.IsType(found) → Assert.False(found) に修正 --- src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs | 5 +++++ .../ChineseG2P/PinyinToMisakiMappingTests.cs | 6 +----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs b/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs index 6612d7d..552082d 100644 --- a/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs +++ b/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs @@ -274,6 +274,11 @@ internal static string ConvertSyllable(PinyinSyllable syllable, bool includeTone suffix = compound.Suffix; omitInitial = compound.OmitInitial; } + else if (syllable.Initial == Initial.Y || syllable.Initial == Initial.W) + { + // Y/W が compound テーブルにない組み合わせ ("yei" 等) は無効 + return string.Empty; + } else { // Standard path: 韻母テンプレートから (Prefix, Suffix) を取得 diff --git a/tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiMappingTests.cs b/tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiMappingTests.cs index a3aabec..f7e18da 100644 --- a/tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiMappingTests.cs +++ b/tests/DotNetG2P.Tests/ChineseG2P/PinyinToMisakiMappingTests.cs @@ -640,11 +640,7 @@ public void YWCompound_None_ReturnsFalse() public void YWCompound_Y_NoMatchingFinal_ReturnsFalse() { // Y + Ei のような未定義の組み合わせはテーブルにないので false - // (pinyin-data の "yei" は極めて稀で Misaki テーブルにない) - var found = PinyinToMisaki.TryGetYWCompound(Initial.Y, Final.Ei, out var _); - // 現仕様では Y+Ei は定義外なので false、ただし実装により変わり得るため - // ここでは結果を取得できること自体を確認 (出力が bool であるかチェック) - Assert.IsType(found); + Assert.False(PinyinToMisaki.TryGetYWCompound(Initial.Y, Final.Ei, out _)); } // ============================================================ From 0505ec6a61c477ecf78e38ed9cc65f5aad2dbbd9 Mon Sep 17 00:00:00 2001 From: yousan Date: Mon, 13 Apr 2026 21:57:03 +0900 Subject: [PATCH 20/21] =?UTF-8?q?fix:=20DocFX=20InvalidFileLink=20?= =?UTF-8?q?=E8=AD=A6=E5=91=8A=E4=BF=AE=E6=AD=A3=20=E2=80=94=20tickets/=20?= =?UTF-8?q?=E3=81=B8=E3=81=AE=E3=83=AA=E3=83=B3=E3=82=AF=E3=82=92=E3=83=86?= =?UTF-8?q?=E3=82=AD=E3=82=B9=E3=83=88=E5=8F=82=E7=85=A7=E3=81=AB=E5=A4=89?= =?UTF-8?q?=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/guides/misaki-milestones.md から docs/tickets/ への相対リンクが DocFX の content スコープ外のため InvalidFileLink 警告 (7件) が発生。 --warningsAsErrors で CI が失敗していた。リンクをテキスト参照に変更。 --- docs/guides/misaki-milestones.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/guides/misaki-milestones.md b/docs/guides/misaki-milestones.md index edc26d0..bc6af14 100644 --- a/docs/guides/misaki-milestones.md +++ b/docs/guides/misaki-milestones.md @@ -8,13 +8,13 @@ DotNetG2P.Chinese に Misaki 互換出力モードを追加する。全3マイ ## チケット一覧 -詳細は [../tickets/README.md](../tickets/README.md) を参照。 +詳細は `docs/tickets/README.md` を参照。 | マイルストーン | チケット | |-------------|---------| -| Mi1 | [T01 マッピングテーブル](../tickets/T01-misaki-mapping-tables.md), [T02 Convert統合](../tickets/T02-misaki-convert-method.md) | -| Mi2 | [T03 API追加](../tickets/T03-engine-api-integration.md), [T04 テスト実装](../tickets/T04-misaki-tests.md) | -| Mi3 | [T05 ドキュメント・QA](../tickets/T05-documentation-qa.md), [T06 リリース準備](../tickets/T06-release-followup.md) | +| Mi1 | T01 マッピングテーブル, T02 Convert統合 | +| Mi2 | T03 API追加, T04 テスト実装 | +| Mi3 | T05 ドキュメント・QA, T06 リリース準備 | --- From c2fb320427b4a7d18d12f25843c1df9602c62b82 Mon Sep 17 00:00:00 2001 From: yousan Date: Mon, 13 Apr 2026 22:15:14 +0900 Subject: [PATCH 21/21] =?UTF-8?q?fix:=20PR=20#59=20=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E4=BF=AE=E6=AD=A3=20?= =?UTF-8?q?=E2=80=94=20Y/W+Er=20=E3=82=AC=E3=83=BC=E3=83=89=E3=83=BBSepara?= =?UTF-8?q?tor=E3=83=86=E3=82=B9=E3=83=88=E3=83=BB=E5=87=BA=E5=8A=9B?= =?UTF-8?q?=E4=BE=8B=E6=95=B4=E5=90=88=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PinyinToMisaki.cs: Y/W 未定義組み合わせの早期リターンを特別ケース判定の前に移動 (Y+Er 等が Er 処理を先に通過する問題を修正) - ChineseMisakiIpaTests.cs: Separator オプション (空文字/ハイフン) テスト2件追加 - misaki-compatible-chinese.md: 出力例を ni↓ xau↓ (スペース区切り) に修正、 音節区切り説明を「語間」→「音節間」に修正 --- docs/guides/misaki-compatible-chinese.md | 6 ++--- .../Conversion/PinyinToMisaki.cs | 12 +++++---- .../ChineseG2P/ChineseMisakiIpaTests.cs | 26 +++++++++++++++++++ 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/docs/guides/misaki-compatible-chinese.md b/docs/guides/misaki-compatible-chinese.md index 535d248..37d86b5 100644 --- a/docs/guides/misaki-compatible-chinese.md +++ b/docs/guides/misaki-compatible-chinese.md @@ -14,9 +14,9 @@ DotNetG2P.ChineseにMisaki互換出力モードを追加することで、C#/Uni | 項目 | Misaki (Legacy) | DotNetG2P 現行 | |------|----------------|----------------| -| 出力例 | `ni↓xau↓` | `ni˧˥ xaʊ˨˩˦` | +| 出力例 | `ni↓ xau↓` | `ni˧˥ xaʊ˨˩˦` | | 声調記号 | 矢印 (`→` `↗` `↓` `↘`) | IPA tone letters (`˥˥` `˧˥` `˨˩˦` `˥˩`) | -| 音節区切り | スペース (語間) | スペース (音節間) | +| 音節区切り | スペース (音節間) | スペース (音節間) | | 声母 j/q | `ʨ` / `ʨʰ` | `tɕ` / `tɕʰ` | | 二重母音 | `ai` `au` `ei` `ou` (非音節化符号は出力に含まれない) | `aɪ` `aʊ` `eɪ` `oʊ` (別字母) | | zh/ch/sh+i | `ɻ̩` / `ʐ̩` | `ɻ̩` | @@ -187,7 +187,7 @@ public IReadOnlyList ToMisakiIPABatch(string[] texts, bool includeTones) 3. **韻母マッピング**: 二重母音の非音節化符号が正しいこと 4. **声調変調**: ToneSandhi の結果が Misaki 出力にも反映されること 5. **エッジケース**: 軽声、er化、句読点、空文字列等 -6. **Misaki 出力例との比較**: issue #56 の `ni↓xau↓` 等 +6. **Misaki 出力例との比較**: issue #56 の `ni↓ xau↓` 等 ## 備考 diff --git a/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs b/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs index 552082d..b006aa3 100644 --- a/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs +++ b/src/DotNetG2P.Chinese/Conversion/PinyinToMisaki.cs @@ -236,6 +236,13 @@ internal static string ConvertSyllable(PinyinSyllable syllable, bool includeTone ? s_toneArrows[(int)syllable.Tone] : string.Empty; + // ステップ 2 の前に: Y/W の未定義組み合わせを早期リターン + if ((syllable.Initial == Initial.Y || syllable.Initial == Initial.W) + && !s_yWCompoundMisaki.ContainsKey((syllable.Initial, syllable.Final))) + { + return string.Empty; + } + // ステップ 2: 特別ケース判定 // a. Initial.None + Final.O → 単独感嘆詞 "ɔ" (NOT wo) @@ -274,11 +281,6 @@ internal static string ConvertSyllable(PinyinSyllable syllable, bool includeTone suffix = compound.Suffix; omitInitial = compound.OmitInitial; } - else if (syllable.Initial == Initial.Y || syllable.Initial == Initial.W) - { - // Y/W が compound テーブルにない組み合わせ ("yei" 等) は無効 - return string.Empty; - } else { // Standard path: 韻母テンプレートから (Prefix, Suffix) を取得 diff --git a/tests/DotNetG2P.Tests/ChineseG2P/ChineseMisakiIpaTests.cs b/tests/DotNetG2P.Tests/ChineseG2P/ChineseMisakiIpaTests.cs index e6ff81a..c4375f2 100644 --- a/tests/DotNetG2P.Tests/ChineseG2P/ChineseMisakiIpaTests.cs +++ b/tests/DotNetG2P.Tests/ChineseG2P/ChineseMisakiIpaTests.cs @@ -830,6 +830,32 @@ public void ToMisakiIPA_中国_各音節が声調矢印を含む() } } + // ===================================================================== + // 10b. Separator オプション + // ===================================================================== + + [Fact] + public void ToMisakiIPA_Separator空文字_スペースなしで連結() + { + var options = new ChineseG2POptions(separator: ""); + using var engine = new ChineseG2PEngine(options); + var result = engine.ToMisakiIPA("\u4F60\u597D"); // 你好 + Assert.DoesNotContain(" ", result); + // デフォルトのスペース区切り結果からスペースを除去した値と一致 + var defaultResult = _engine.ToMisakiIPA("\u4F60\u597D"); + Assert.Equal(defaultResult.Replace(" ", ""), result); + } + + [Fact] + public void ToMisakiIPA_Separatorハイフン_区切り文字が変更される() + { + var options = new ChineseG2POptions(separator: "-"); + using var engine = new ChineseG2PEngine(options); + var result = engine.ToMisakiIPA("\u4F60\u597D"); // 你好 + Assert.Contains("-", result); + Assert.DoesNotContain(" ", result); + } + // ===================================================================== // 11. 標準 IPA / piper-plus との比較 // =====================================================================