jvs音声データセットを使ったpiper日本語モデルの作成

初めに

前回の記事でljspeechデータセットを使った英語のモデルを作成しました。今回は日本語モデルを作成していきます

開発環境

前処理や調査

学習環境

データセットの準備

jvsデータセットをljspeechデータセットフォーマットに変換

まずは piperで学習する際にljspeechフォーマットになっているのが好ましいため、jvsデータセットをljspeechデータセットのフォーマットに変換をします

まずはjvsデータセットをダウンロードして以下のような構造にします

C:\Users\yuta\Downloads\jvs_ver1\
├── .venv/
├── jvs_ver1/
│   ├── jvs001/
│   ├── jvs002/
│   ├── jvs003/
│   ├── jvs004/
│   ├── ..... (~100まで)
│   ├── duration_info.txt
│   ├── gender_f0range.txt
│   ├── paper.pdf
│   ├── README.txt
│   ├── speaker_similarity_female.csv
│   └── speaker_similarity_male.csv
├── result/
├── jvs_to_ljspeech_multi.py
├── jvs_to_ljspeech.py
└── process_japanese_metadata_plus.py

jvsのデータ構造からljspeechに変換するために、以下のスクリプトを実行します。piperの学習において複数話者の場合は、metadataの構造を以下のようにする必要があります

id|話者名|文字お越し

具体的には以下のようなデータになります

jvs001_falset10_VOICEACTRESS100_001|jvs001|また、東寺のように、五大明王と呼ばれる、主要な明王の中央に配されることも多い。
jvs001_falset10_VOICEACTRESS100_002|jvs001|ニューイングランド風は、牛乳をベースとした、白いクリームスープであり、ボストンクラムチャウダーとも呼ばれる。

以下が実際の変換用のコードです

import os
import shutil
import csv

def convert_jvs_to_multispeaker_format(jvs_root_dir, output_dir_name="result", metadata_filename="metadata_multispeaker.csv"):
    """
    JVSデータセットを複数話者TTS学習用のフォーマットに変換する。
    metadataファイルは3列構成 (ID拡張子なし|話者ID|テキスト)。
    出力WAVファイル名は speaker_dir_name と sub_dir_name を含めることで重複を回避。

    Args:
        jvs_root_dir (str): JVSデータセットのルートディレクトリパス。
        output_dir_name (str): 出力する親フォルダの名前。
        metadata_filename (str): 出力するメタデータファイルの名前。
    """
    current_path = os.getcwd()
    output_base_dir = os.path.join(current_path, output_dir_name)
    output_wavs_dir = os.path.join(output_base_dir, "wavs")
    output_metadata_file = os.path.join(output_base_dir, metadata_filename)

    if os.path.exists(output_base_dir):
        print(f"警告: 出力ディレクトリ '{output_base_dir}' は既に存在します。中のファイルが上書きされる可能性があります。")
    os.makedirs(output_wavs_dir, exist_ok=True)

    metadata_list = [] # メタデータを格納するリスト
    processed_files_count = 0
    missing_files_os_path_exists_count = 0
    missing_files_is_file_count = 0
    missing_files_open_rb_count = 0
    copy_errors_count = 0

    print(f"JVSデータセットの処理を開始します: {jvs_root_dir}")
    print(f"出力先ディレクトリ: {output_base_dir}")

    for speaker_dir_name in sorted(os.listdir(jvs_root_dir)): # 例: jvs001, jvs002
        speaker_path = os.path.join(jvs_root_dir, speaker_dir_name)
        if not os.path.isdir(speaker_path):
            continue

        print(f"  話者ディレクトリを処理中: {speaker_dir_name}")

        for sub_dir_name in sorted(os.listdir(speaker_path)): # 例: falset10, nonpara30
            sub_dir_path = os.path.join(speaker_path, sub_dir_name)
            if not os.path.isdir(sub_dir_path):
                continue

            transcript_file_path = os.path.join(sub_dir_path, 'transcripts_utf8.txt')
            actual_wav_files_location = None
            potential_wav_subfolder_names = ['wav24kHz16bit', 'wav48kHz16bit', 'wav', 'voice']

            for folder_name in potential_wav_subfolder_names:
                current_check_dir = os.path.join(sub_dir_path, folder_name)
                if os.path.isdir(current_check_dir):
                    if any(f.endswith('.wav') for f in os.listdir(current_check_dir)):
                        actual_wav_files_location = current_check_dir
                        break

            if not actual_wav_files_location:
                if os.path.isdir(sub_dir_path) and any(f.endswith('.wav') for f in os.listdir(sub_dir_path)):
                    actual_wav_files_location = sub_dir_path

            if not actual_wav_files_location:
                if os.path.exists(transcript_file_path):
                    print(f"    警告: '{sub_dir_path}' 内で音声ファイルの場所を特定できませんでした (トランスクリプト '{transcript_file_path}' は存在します)。このサブディレクトリをスキップします。")
                continue

            if os.path.exists(transcript_file_path):
                print(f"    処理中のサブディレクトリ: '{sub_dir_path}' (音声ファイルの場所: '{actual_wav_files_location}')")
                print(f"      トランスクリプトファイル: '{transcript_file_path}'")

                with open(transcript_file_path, 'r', encoding='utf-8') as f:
                    for line_number, line in enumerate(f, 1):
                        line = line.strip()
                        if not line:
                            continue

                        try:
                            original_audio_basename, text = line.split(':', 1) # original_audio_basename は拡張子なし
                        except ValueError:
                            print(f"        警告 (行 {line_number}): 不正な行フォーマットです: \"{line}\"。スキップします。")
                            continue

                        original_wav_filename_with_ext = original_audio_basename + ".wav"
                        source_wav_path = os.path.join(actual_wav_files_location, original_wav_filename_with_ext)

                        if not os.path.exists(source_wav_path):
                            missing_files_os_path_exists_count += 1
                            continue

                        if not os.path.isfile(source_wav_path):
                            print(f"        警告 (行 {line_number}): パスは存在するがファイルではありません (os.path.isfile): '{source_wav_path}'。スキップします。")
                            missing_files_is_file_count += 1
                            continue
                        
                        try:
                            with open(source_wav_path, 'rb') as f_test:
                                pass
                        except Exception as e_test:
                            print(f"        警告 (行 {line_number}): ファイルは存在するが、開けません (open 'rb' test): '{source_wav_path}'。エラー: {e_test}。スキップします。")
                            missing_files_open_rb_count += 1
                            continue
                        
                        # --- メタデータ用IDと実際のコピーファイル名の生成 ---
                        # メタデータ用ID (拡張子なし)
                        file_id_no_ext = f"{speaker_dir_name}_{sub_dir_name}_{original_audio_basename}"
                        # 実際にコピーされるファイル名 (拡張子あり)
                        copied_wav_filename_with_ext = f"{speaker_dir_name}_{sub_dir_name}_{original_wav_filename_with_ext}"
                        destination_wav_path = os.path.join(output_wavs_dir, copied_wav_filename_with_ext)

                        try:
                            shutil.copy2(source_wav_path, destination_wav_path)
                            processed_files_count +=1
                        except FileNotFoundError:
                            print(f"        重大エラー (行 {line_number}, shutil.copy2 FileNotFoundError): '{source_wav_path}' がコピー時に見つかりませんでした。")
                            print(f"          再確認: os.path.exists={os.path.exists(source_wav_path)}, os.path.isfile={os.path.isfile(source_wav_path)}")
                            copy_errors_count += 1
                            continue
                        except Exception as e:
                            print(f"        エラー (行 {line_number}): 音声ファイルのコピーに失敗しました: '{source_wav_path}' -> '{destination_wav_path}'。エラー: {e}")
                            copy_errors_count += 1
                            continue

                        # 複数話者用メタデータとして (ID拡張子なし, 話者ID, テキスト) の3列を追加
                        speaker_id = speaker_dir_name # 話者IDはjvsXXXなど
                        metadata_list.append([file_id_no_ext, speaker_id, text])

    print(f"\n--- 処理結果サマリー ({metadata_filename}) ---")
    if metadata_list:
        with open(output_metadata_file, 'w', encoding='utf-8', newline='') as f_out:
            writer = csv.writer(f_out, delimiter='|', quoting=csv.QUOTE_NONE, escapechar='\\')
            for row in metadata_list:
                writer.writerow(row)
        print(f"  メタデータは '{output_metadata_file}' に複数話者フォーマット (3列) で保存されました。")
        print(f"  処理され、コピーされた音声ファイルの総数: {processed_files_count} 個")
        if processed_files_count > 0 :
             print(f"  コピーされた音声ファイルは '{output_wavs_dir}' にあります。")
    elif processed_files_count == 0 :
        print(f"  処理できる音声ファイルが見つからなかったため、メタデータファイルは作成されませんでした。")

    if missing_files_os_path_exists_count > 0:
        print(f"  トランスクリプト記載があったが、存在しなかったファイル数 (os.path.exists): {missing_files_os_path_exists_count} 個")
    if missing_files_is_file_count > 0:
        print(f"  パスは存在したがファイルではなかった数 (os.path.isfile): {missing_files_is_file_count} 個")
    if missing_files_open_rb_count > 0:
        print(f"  ファイルは存在したが開けなかった数 (open 'rb' test): {missing_files_open_rb_count} 個")
    if copy_errors_count > 0:
        print(f"  ファイルコピー時にエラーが発生した数: {copy_errors_count} 個")
    
    if not metadata_list and processed_files_count == 0 and \
       missing_files_os_path_exists_count == 0 and missing_files_is_file_count == 0 and \
       missing_files_open_rb_count == 0 and copy_errors_count == 0 :
        print(f"  処理対象のデータが全く見つかりませんでした。JVSのパスや構造を確認してください。")

# (ここに前回の convert_jvs_to_piper_format 関数をそのまま残すことも可能です)
# def convert_jvs_to_piper_format(jvs_root_dir, output_dir_name="result"):
#     ... (前回のコード) ...


if __name__ == '__main__':
    jvs_input_path = r"C:\Users\yuta\Downloads\jvs_ver1\jvs_ver1"
    output_folder_name = "result" # 親フォルダ result

    print(f"スクリプトの実行パス: {os.getcwd()}")
    print(f"JVSデータセットの入力パス: {jvs_input_path}")
    print(f"出力フォルダ名: {output_folder_name}")

    if not os.path.isdir(jvs_input_path):
        print(f"エラー: 指定されたJVSデータセットのパスが見つかりません: {jvs_input_path}")
    else:
        # --- Piper用メタデータ生成を呼び出す場合 (ファイル名: metadata.csv) ---
        # convert_jvs_to_piper_format(jvs_input_path, output_folder_name) 

        # --- 今回ご要望の複数話者用メタデータ生成を呼び出す場合 (ファイル名: metadata_multispeaker.csv) ---
        convert_jvs_to_multispeaker_format(jvs_input_path, output_folder_name, metadata_filename="metadata_multispeaker.csv")
        
        # --- もし両方生成したい場合は、両方の関数を呼び出すか、
        # --- WAVコピー処理を共通化してメタデータ生成部分を分けるようにスクリプトをリファクタリングします。
        # --- ここでは、新しい複数話者用フォーマットのみを生成する例としています。

これを実行することで、resultフォルダ内にwavsフォルダとmetadata.csvが生成されます

学習コード及び推論コードの日本語の音素の対応

piperのオリジナルリポジトリには、日本語の音素対応がしないため 学習をすると以下のwaringが出ます。このまま学習をしても日本語が正常に発話されません。

推論時に以下のようなwaringが出てきます

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̈

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ᵝ

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̈

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ᵝ

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ᵝ

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̈

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ᵝ

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ̞

WARNING:piper.voice:Missing phoneme from id map: ᵝ

そのため、以下のような対応を入れました

  • pyopenjtalk を利用する専用 phonemizer phonemize_japanese を実装

    • アクセント句境界 #・上昇 [・下降 ] などのプロソディ記号を付与
    • 無声化母音 A I U E O → a i u e o に変換
    • sil → 文頭 ^ / 文末 $ or ?、pau → _
  • 日本語用 ID マップ jp_id_map.py を追加

    • 特殊トークン(^ $ ? # [ ] )+ 50 余りの音素を列挙
  • --language ja 時は PhonemeType.OPENJTALK を強制し ID マップを注入

  • 生成した config.json に phoneme_type=openjtalk, phoneme_id_map を保存

細かい対応は以下の差分を参考にしてください

github.com

学習

データセットが終わったら、以下の記事と同じように配置を行い学習を行います

ayousanz.hatenadiary.jp

途中から学習を再開する場合は、学習コマンドに --resume_from_checkpoint "${CHECKPOINT_PATH}" を付けて実行します

epoch650学習を行い以下がログになります

推論

学習した後に推論をするためにonnxに変換をします

python3 -m piper_train.export_onnx \
  /data/piper_jvs_preprocessed_espeak_ms/lightning_logs/version_1/checkpoints/epoch=650-step=145824.ckpt \
  /data/piper_MODELS_EXPORTED/jvs_piper_multi_epoch650.onnx

config.jsonも必要なため、コピーしてきます

cp /data/piper_jvs_preprocessed_espeak_ms/config.json \
  /data/piper_MODELS_EXPORTED/jvs_piper_multi_epoch650.onnx.json

以下のコマンドでonnxに変換したモデルから日本語の推論を行います

echo "これは650エポック学習した、私の新しい日本語音声モデルです。聞いてみましょう。" |   piper     -m /data/piper_MODELS_EXPORTED/jvs_piper_multi_epoch650.onnx     --output_file jvs_epoch650_test.wav     --speaker 0

実際の音声は以下になります

youtu.be