初めに
今回は、piper-plusの.NET版(PiperPlus.Core + DotNetG2Pパッケージ群)を使って、C#から多言語の音素変換(G2P: Grapheme-to-Phoneme)を行う方法を解説します。
piper-plusの.NET版はeSpeak-ng(GPLライセンス)への依存がなく、MITライセンスで提供しています。日本語、英語、中国語、韓国語、スペイン語、フランス語、ポルトガル語、スウェーデン語の8言語のG2P(音素変換)に対応しています。
なお、韓国語(ko)とスウェーデン語(sv)はG2P処理は動作しますが、現時点ではTTSモデルが公開されていないため、音声合成まで行う場合はja、en、zh、es、fr、ptの6言語が対象となります。
開発環境
- OS: Windows 11(macOS / Linuxでも動作)
- .NET SDK: 9.0(PiperPlus.Coreはnet8.0対応)
環境構築
プロジェクトの作成
dotnet new console -n G2PDemo cd G2PDemo
パッケージのインストール
PiperPlus.Core(v0.2.0)にはPhonemizerインターフェースと各言語のPhonemizer実装が含まれていますが、G2Pエンジンの実体は別途DotNetG2Pパッケージとしてインストールする必要があります。
# コアライブラリ(IPhonemizer、各言語Phonemizer) dotnet add package PiperPlus.Core --version 0.2.0 # 日本語G2Pエンジン(OpenJTalk/MeCabベース) dotnet add package DotNetG2P --version 1.9.0 dotnet add package DotNetG2P.MeCab --version 1.9.0 # 英語G2Pエンジン(CMU Dictionary + g2p-enベース) dotnet add package DotNetG2P.English --version 1.9.0 # 中国語G2Pエンジン(ピンイン辞書ベース) dotnet add package DotNetG2P.Chinese --version 1.9.0 # 韓国語 — PiperPlus.Cli内蔵のハングル分解で動作(追加パッケージ不要) # スペイン語G2Pエンジン(ルールベース) dotnet add package DotNetG2P.Spanish --version 1.9.0 # フランス語G2Pエンジン(ルールベース) dotnet add package DotNetG2P.French --version 1.9.0 # ポルトガル語G2Pエンジン(ルールベース) dotnet add package DotNetG2P.Portuguese --version 1.9.0 # スウェーデン語 — PiperPlus.Core内蔵のSwedishG2PEngineで動作(追加パッケージ不要)
全言語を使う場合は上記すべてをインストールします。特定の言語だけ使う場合は必要なパッケージのみで構いません。
実行
日本語の音素変換
日本語の音素変換にはDotNetG2PとDotNetG2P.MeCabを使用します。PiperPlus.CliのG2Pエンジンクラス(DotNetG2PEngine)はinternal宣言のため、IJapaneseG2PEngineを実装するアダプタ(MyJapaneseG2PAdapter.cs)を用意します。
using DotNetG2P; using DotNetG2P.MeCab; using PiperPlus.Core.Config; using PiperPlus.Core.Phonemize; // IJapaneseG2PEngineアダプタ // PiperPlus.CliのDotNetG2PEngineはinternalのため、同等のアダプタを実装する public sealed class MyJapaneseG2PAdapter : IJapaneseG2PEngine { private readonly G2PEngine _engine; public MyJapaneseG2PAdapter(G2PEngine engine) { _engine = engine; } public G2PResult Convert(string text) { var features = _engine.ToProsodyFeatures(text); var phonemes = new string[features.Phonemes.Count]; var a1 = new int[features.A1.Count]; var a2 = new int[features.A2.Count]; var a3 = new int[features.A3.Count]; for (int i = 0; i < features.Phonemes.Count; i++) phonemes[i] = features.Phonemes[i]; for (int i = 0; i < features.A1.Count; i++) a1[i] = features.A1[i]; for (int i = 0; i < features.A2.Count; i++) a2[i] = features.A2[i]; for (int i = 0; i < features.A3.Count; i++) a3[i] = features.A3[i]; return new G2PResult(phonemes, a1, a2, a3); } }
上記のアダプタを使ってProgram.csで音素変換を実行します。
using DotNetG2P; using DotNetG2P.MeCab; using PiperPlus.Core.Config; using PiperPlus.Core.Phonemize; // DotNetG2P.MeCabのMeCabTokenizerを使ってG2Pエンジンを構築 // DictionaryManagerがnaist-jdic辞書を自動ダウンロード var dictPath = await DictionaryManager.EnsureDictionaryAsync(); var tokenizer = new MeCabTokenizer(dictPath); var g2pEngine = new G2PEngine(tokenizer); var jaPhonemizer = new JapanesePhonemizer(new MyJapaneseG2PAdapter(g2pEngine)); var phonemes = jaPhonemizer.Phonemize("こんにちは"); Console.WriteLine(string.Join(" ", phonemes));
実行します。
dotnet run
^ k o [ n i i w a $
Phonemize()はList<string>を返すため、文字列として表示するにはstring.Join()で結合します。韻律記号(^ = BOS、$ = EOS、[ = ピッチ上昇、] = ピッチ下降、# = アクセント句境界)が含まれます。なお、実際の出力では複数文字の音素トークン(ch、N_n等)はPrivate Use Area(PUA)の単一コードポイントにマッピングされます。
英語の音素変換
英語も同様にIEnglishG2PEngineを実装するアダプタ(MyEnglishG2PAdapter.cs)を用意し、EnglishPhonemizerに渡します。G2PエンジンにはDotNetG2P.Englishを使用します。
using DotNetG2P.English; using PiperPlus.Core.Phonemize; public sealed class MyEnglishG2PAdapter : IEnglishG2PEngine { private readonly EnglishG2PEngine _engine = new(); public List<List<string>> ConvertToArpabet(string text) { var result = new List<List<string>>(); foreach (string token in text.Split(' ', StringSplitOptions.RemoveEmptyEntries)) { string word = token.Trim(); if (string.IsNullOrEmpty(word)) continue; var phonemes = _engine.LookupWord(word); if (phonemes.Count > 0) { var wordPhonemes = new List<string>(phonemes.Count); foreach (var p in phonemes) wordPhonemes.Add(p.ToString()); result.Add(wordPhonemes); } else { result.Add([word]); } } return result; } }
Program.csを以下の内容に書き換えて実行します。
using PiperPlus.Core.Phonemize; var enPhonemizer = new EnglishPhonemizer(new MyEnglishG2PAdapter()); var phonemes = enPhonemizer.Phonemize("Hello, world!"); Console.WriteLine(string.Join(" ", phonemes));
dotnet run
H e l l o , w o r l d !
他の言語(中国語、韓国語、スペイン語、フランス語、ポルトガル語、スウェーデン語)も同様のパターンで実装できます。各言語のインターフェースと追加パッケージは「各言語のG2Pエンジン一覧」を参照してください。
マルチリンガル音素変換
MultilingualPhonemizerを使うと、複数言語が混在するテキストをUnicode範囲で自動判別して処理できます。Program.csを以下の内容に書き換えて実行します。
using DotNetG2P; using DotNetG2P.MeCab; using PiperPlus.Core.Config; using PiperPlus.Core.Phonemize; // 日本語・英語のPhonemizerを構築 var dictPath = await DictionaryManager.EnsureDictionaryAsync(); var tokenizer = new MeCabTokenizer(dictPath); var jaPhonemizer = new JapanesePhonemizer(new MyJapaneseG2PAdapter(new G2PEngine(tokenizer))); var enPhonemizer = new EnglishPhonemizer(new MyEnglishG2PAdapter()); // MultilingualPhonemizerに渡す var phonemizers = new Dictionary<string, IPhonemizer> { ["ja"] = jaPhonemizer, ["en"] = enPhonemizer, }; var multiPhonemizer = new MultilingualPhonemizer(phonemizers, defaultLatinLanguage: "en"); var phonemes = multiPhonemizer.Phonemize("今日はgood dayですね。"); Console.WriteLine(string.Join(" ", phonemes));
dotnet run
o ] [ o w a ɡ ˈ ʊ d d ˈ e ɪ d e ] [ s U n e
PiperPlus.CliのResolveTextModePhonemizer()では、言語コードをハイフン区切り(例: ja-en-zh)で指定すると自動的にMultilingualPhonemizerが構築されます。
韻律情報付きの音素変換
PhonemizeWithProsody()を使うと、各音素トークンに対応する韻律情報(ProsodyInfo)も取得できます。Program.csを以下の内容に書き換えて実行します。
using DotNetG2P; using DotNetG2P.MeCab; using PiperPlus.Core.Config; using PiperPlus.Core.Phonemize; var dictPath = await DictionaryManager.EnsureDictionaryAsync(); var tokenizer = new MeCabTokenizer(dictPath); var jaPhonemizer = new JapanesePhonemizer(new MyJapaneseG2PAdapter(new G2PEngine(tokenizer))); var (tokens, prosody) = jaPhonemizer.PhonemizeWithProsody("今日はいい天気ですね。"); for (int i = 0; i < tokens.Count; i++) { var p = prosody[i]; if (p.HasValue) Console.WriteLine($"{tokens[i]}\tA1={p.Value.A1} A2={p.Value.A2} A3={p.Value.A3}"); else Console.WriteLine($"{tokens[i]}\t(prosody: none)"); }
dotnet run
^ (prosody: none)
A1=0 A2=1 A3=3
o A1=0 A2=1 A3=3
] (prosody: none)
[ (prosody: none)
o A1=1 A2=2 A3=2
w A1=2 A2=3 A3=1
a A1=2 A2=3 A3=1
i A1=0 A2=1 A3=2
] (prosody: none)
[ (prosody: none)
i A1=1 A2=2 A3=1
t A1=0 A2=1 A3=6
e A1=0 A2=1 A3=6
] (prosody: none)
[ (prosody: none)
A1=1 A2=2 A3=5
k A1=2 A2=3 A3=4
i A1=2 A2=3 A3=4
d A1=3 A2=4 A3=3
e A1=3 A2=4 A3=3
s A1=4 A2=5 A3=2
U A1=4 A2=5 A3=2
n A1=5 A2=6 A3=1
e A1=5 A2=6 A3=1
$ (prosody: none)
APIアーキテクチャ
アーキテクチャとしては、PiperPlus.Coreが各言語のPhonemizer(JapanesePhonemizer、EnglishPhonemizerなど)とG2Pエンジンのインターフェース(IJapaneseG2PEngine、IEnglishG2PEngineなど)を定義し、実際のG2Pエンジン実装はDotNetG2Pパッケージ群としてPiperPlus.Cli側に配置しています。スウェーデン語のみ、ルールベースのG2Pエンジン(SwedishG2PEngine)をPiperPlus.Coreに組み込んでいます。
IPhonemizer インターフェース
PiperPlus.Core.Phonemize名前空間に定義されたIPhonemizerインターフェースが全言語共通の契約です。
public interface IPhonemizer { List<string> Phonemize(string text); (List<string> Tokens, List<ProsodyInfo?> Prosody) PhonemizeWithProsody(string text); Dictionary<string, int[]>? GetPhonemeIdMap(); // デフォルト実装付き — BOS/EOSトークン挿入やパディングなどの後処理 (List<int> Ids, List<ProsodyInfo?> Prosody) PostProcessIds( List<int> phonemeIds, List<ProsodyInfo?> prosodyFeatures, Dictionary<string, int[]> phonemeIdMap) { return (phonemeIds, prosodyFeatures); } }
Phonemize()は音素トークンのList<string>を返します。PhonemizeWithProsody()はトークンに加えて韻律情報(A1/A2/A3)も返します。GetPhonemeIdMap()は言語固有の音素-IDマッピングを返します(nullの場合はconfig.jsonのマップが使用されます)。
PostProcessIds()はデフォルト実装付きのメソッドで、トークン→ID変換後にBOS/EOSトークンの挿入や音素間パディングなど、言語固有のID列変換を行います。デフォルト実装では入力をそのまま返します(no-op)。言語ごとにオーバーライドして使用します。
Phonemizerの構築パターン
各言語のPhonemizerは、対応するG2Pエンジンをコンストラクタで受け取ります。PhonemizerRegistryのようなサービスロケータは存在しません。
using PiperPlus.Core.Phonemize; // 各Phonemizerは対応するG2Pエンジンをコンストラクタに渡して構築 var jaPhonemizer = new JapanesePhonemizer(jaG2PEngine); // IJapaneseG2PEngine var enPhonemizer = new EnglishPhonemizer(enG2PEngine); // IEnglishG2PEngine var zhPhonemizer = new ChinesePhonemizer(zhG2PEngine); // IChineseG2PEngine var koPhonemizer = new KoreanPhonemizer(koG2PEngine); // IKoreanG2PEngine var esPhonemizer = new SpanishPhonemizer(esG2PEngine); // ISpanishG2PEngine var frPhonemizer = new FrenchPhonemizer(frG2PEngine); // IFrenchG2PEngine var ptPhonemizer = new PortuguesePhonemizer(ptG2PEngine); // IPortugueseG2PEngine var svPhonemizer = new SwedishPhonemizer(svG2PEngine); // ISwedishG2PEngine
韻律情報の詳細
韻律情報の意味は言語によって異なります。
| 言語 | A1 | A2 | A3 |
|---|---|---|---|
| ja | アクセント核からの相対位置 | モーラ位置(1始まり) | アクセント句内モーラ数 |
| en | 固定0 | ストレスレベル(0/1/2) | 単語内音素数 |
| zh | 声調(1-5) | 語内音節位置(1始まり) | 語の音節数 |
| ko | 固定0 | 固定0 | ハングル音節数 |
| es | 固定0 | ストレスレベル(0/2) | 単語内音素数 |
| fr | 固定0 | ストレスレベル(0/2) | 単語内音素数 |
| pt | 固定0 | ストレスレベル(0/2) | 単語内音素数 |
| sv | 固定0 | ストレスレベル(0/1/2) | 単語内音素数 |
各言語のG2Pエンジン一覧
| 言語 | Phonemizer | G2Pエンジンインターフェース | 具体実装(PiperPlus.Cli) | 追加パッケージ |
|---|---|---|---|---|
| ja | JapanesePhonemizer |
IJapaneseG2PEngine |
DotNetG2PEngine |
DotNetG2P + DotNetG2P.MeCab |
| en | EnglishPhonemizer |
IEnglishG2PEngine |
DotNetEnglishG2PEngine |
DotNetG2P.English |
| zh | ChinesePhonemizer |
IChineseG2PEngine |
DotNetChineseG2PEngine |
DotNetG2P.Chinese |
| ko | KoreanPhonemizer |
IKoreanG2PEngine |
DotNetKoreanG2PEngine |
なし(Cli内蔵) |
| es | SpanishPhonemizer |
ISpanishG2PEngine |
DotNetSpanishG2PEngine |
DotNetG2P.Spanish |
| fr | FrenchPhonemizer |
IFrenchG2PEngine |
DotNetFrenchG2PEngine |
DotNetG2P.French |
| pt | PortuguesePhonemizer |
IPortugueseG2PEngine |
DotNetPortugueseG2PEngine |
DotNetG2P.Portuguese |
| sv | SwedishPhonemizer |
ISwedishG2PEngine |
SwedishG2PEngine |
なし(Core内蔵) |
応用例
バッチ処理: 大量テキストの一括音素変換
テキストファイルから一括で音素変換を行う例です。input.txtを用意し、Program.csを以下の内容に書き換えて実行します。
using DotNetG2P; using DotNetG2P.MeCab; using PiperPlus.Core.Config; using PiperPlus.Core.Phonemize; var dictPath = await DictionaryManager.EnsureDictionaryAsync(); var tokenizer = new MeCabTokenizer(dictPath); var jaPhonemizer = new JapanesePhonemizer(new MyJapaneseG2PAdapter(new G2PEngine(tokenizer))); var lines = await File.ReadAllLinesAsync("input.txt"); var results = new List<string>(); foreach (var line in lines) { if (string.IsNullOrWhiteSpace(line)) continue; var phonemes = jaPhonemizer.Phonemize(line); results.Add($"{line}\t{string.Join(" ", phonemes)}"); } await File.WriteAllLinesAsync("output.tsv", results); Console.WriteLine($"{results.Count}行の音素変換が完了しました。");
dotnet run
3行の音素変換が完了しました。
Python版との比較
Python版(piper-plus-g2p)と.NET版のAPI設計を比較します。
| 機能 | Python | C# (.NET) |
|---|---|---|
| Phonemizer取得 | get_phonemizer("ja") |
new JapanesePhonemizer(engine) |
| 音素変換 | phonemizer.phonemize(text) |
phonemizer.Phonemize(text) → List<string> |
| 韻律付き変換 | phonemizer.phonemize_with_prosody(text) |
phonemizer.PhonemizeWithProsody(text) |
| マルチリンガル | MultilingualPhonemizer({...}) |
new MultilingualPhonemizer(dict) |
| パッケージ | uv add "piper-plus-g2p[ja]" |
PiperPlus.Core + DotNetG2P.* |
| ライセンス | MIT | MIT |
Python版はget_phonemizer("ja")のようなファクトリ関数で手軽に取得できるのに対し、.NET版はG2Pエンジンを明示的にコンストラクタで注入するDI(Dependency Injection)パターンを採用しています。テストではG2Pエンジンをモックに差し替えられるメリットがあります。
Python版では言語ごとのextras([ja], [en])で依存関係を細かく制御できます。.NET版も同様に、DotNetG2Pパッケージを言語ごとに選択してインストールできます。スウェーデン語はPiperPlus.Core内蔵のSwedishG2PEngineで、韓国語はPiperPlus.Cli内蔵のDotNetKoreanG2PEngineで動作し、いずれも追加パッケージは不要です。ただし、韓国語とスウェーデン語はG2P処理のみ対応しており、TTSモデルは現時点で未公開です。
所感
今回は.NET版のpiper-plusでG2P(音素変換)処理を試してみました。
.NETでMITライセンスのG2Pライブラリが使えるのは、C#のプロダクトに音素変換を組み込みたい場合に便利です。G2Pエンジンのインターフェースが分離されているため、テストやカスタムエンジンとの差し替えが容易です。Python版と同じ8言語をサポートしており、CIレベルで出力の一致が検証されているため、言語間でのポーティングも安心して行えます。