piper-plusの.NET版で多言語音素変換を実装する

初めに

今回は、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言語が対象となります。

github.com

開発環境

  • 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で動作(追加パッケージ不要)

全言語を使う場合は上記すべてをインストールします。特定の言語だけ使う場合は必要なパッケージのみで構いません。

実行

日本語の音素変換

日本語の音素変換にはDotNetG2PDotNetG2P.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、[ = ピッチ上昇、] = ピッチ下降、# = アクセント句境界)が含まれます。なお、実際の出力では複数文字の音素トークン(chN_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.CliResolveTextModePhonemizer()では、言語コードをハイフン区切り(例: 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(JapanesePhonemizerEnglishPhonemizerなど)とG2Pエンジンのインターフェース(IJapaneseG2PEngineIEnglishG2PEngineなど)を定義し、実際の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レベルで出力の一致が検証されているため、言語間でのポーティングも安心して行えます。