piperモデルからつくよみちゃんデータセットを使って追加学習を行う

初めに

以下の記事でjvsデータセットを用いてpiperモデルの日本語化対応を行いました。

ayousanz.hatenadiary.jp

しかし、このモデルはあくまで事前学習モデルであって特定の話者に特化しているものではありません。そのため、今回は 以下のつくよみちゃんデータセットを用いて 追加学習を行い 特定話者の声のモデルを作成していきます

tyc.rei-yumesaki.net

Demo

追加学習をした後の音声は以下のようになり、つくよみちゃんの声になっていることがわかります

youtu.be

開発環境

前処理

追加学習をする際に使用するデータセットを ljspeechフォーマットに変換しておく必要があります。

事前学習モデルから 追加学習用のモデルを作成

事前学習モデルからそのまま別のデータセットを使って追加学習をすることができないため、以下の対応を事前に行います。

  • モデルの重みだけをロードし、オプティマイザの状態はロードせずに、新しく初期化
  • 話者埋め込み層を削除
import torch
import sys
import os

def create_partial_checkpoint_for_finetuning(original_ckpt_path, new_ckpt_path):
    """
    ファインチューニングのために、不整合なレイヤー(話者関連の層)と
    オプティマイザの状態を削除した新しいチェックポイントを作成します。
    """
    print(f"Loading original checkpoint from: {original_ckpt_path}")
    
    try:
        # CPUにロードしてGPUメモリを節約
        checkpoint = torch.load(original_ckpt_path, map_location="cpu")
    except FileNotFoundError:
        print(f"ERROR: Checkpoint file not found at {original_ckpt_path}", file=sys.stderr)
        return

    original_state_dict = checkpoint["state_dict"]
    
    # 削除するキーのリスト (エラーログに表示されたUnexpected keys)
    # これらは主に、話者数が100から1に変わったことで不要になったレイヤーです。
    keys_to_remove = [
        # 話者埋め込み層と関連する条件付けレイヤー
        "model_g.emb_g.weight",
        "model_g.dec.cond.weight",
        "model_g.dec.cond.bias",
        "model_g.enc_q.enc.cond_layer.bias",
        "model_g.enc_q.enc.cond_layer.weight_g",
        "model_g.enc_q.enc.cond_layer.weight_v",
        "model_g.dp.cond.weight",
        "model_g.dp.cond.bias",
    ]
    # フローの条件付けレイヤーも動的にリストに追加
    for i in [0, 2, 4, 6]:
        for suffix in ["bias", "weight_g", "weight_v"]:
            keys_to_remove.append(f"model_g.flow.flows.{i}.enc.cond_layer.{suffix}")
    
    keys_to_remove_set = set(keys_to_remove)

    # 新しいstate_dictから、削除対象のキーを除外して作成
    new_state_dict = {key: value for key, value in original_state_dict.items() if key not in keys_to_remove_set}

    # チェックポイントのstate_dictを新しいものに更新
    checkpoint["state_dict"] = new_state_dict
    
    # オプティマイザの状態をチェックポイントから削除 (★今回の修正点★)
    if "optimizer_states" in checkpoint:
        del checkpoint["optimizer_states"]
        print("Removed optimizer states from the checkpoint.")
    
    # 新しい部分的なチェックポイントを保存
    torch.save(checkpoint, new_ckpt_path)

    print("\n--- Checkpoint Modification Summary ---")
    print(f"Original state_dict had {len(original_state_dict)} keys.")
    print(f"Removed {len(original_state_dict) - len(new_state_dict)} keys.")
    print(f"New state_dict has {len(new_state_dict)} keys.")
    print("---------------------------------------")
    print(f"\nPartial checkpoint for fine-tuning saved to: {new_ckpt_path}")
    print("\nNow, use this new partial checkpoint path for the --resume_from_checkpoint argument.")

if __name__ == "__main__":
    # --- ★設定箇所★ ---
    # 元のJVSモデルのチェックポイントパス
    original_checkpoint = "/data/piper_jvs_preprocessed_espeak_ms/lightning_logs/version_0/checkpoints/epoch=579-step=129920.ckpt"

    # 保存する新しい「部分的チェックポイント」のパスとファイル名
    partial_checkpoint = "/data/piper_jvs_preprocessed_espeak_ms/lightning_logs/version_0/checkpoints/epoch=579_partial_for_finetune.ckpt"
    # --- 設定ここまで ---

    create_partial_checkpoint_for_finetuning(original_checkpoint, partial_checkpoint)

実行すると以下のようなログが出てきます。こちらで追加学習する用のモデルに変換できました

--- Checkpoint Modification Summary ---
Original state_dict had 804 keys.
Removed 20 keys.
New state_dict has 784 keys.
---------------------------------------

追加学習

以下で追加学習に使用する先ほど変換したモデルとデータセットのパスを環境変数に定義して、学習を行なっていきます

export PARTIAL_CHECKPOINT_PATH="/data/piper_jvs_preprocessed_espeak_ms/lightning_logs/version_0/checkpoints/epoch=579_partial_for_finetune.ckpt"
export PREPROCESSED_TSUKUYOMI_DIR="/data/piper_tsukuyomi_preprocessed_rawtext"

python3 -m piper_train \
  --dataset-dir ${PREPROCESSED_TSUKUYOMI_DIR} \
  --accelerator 'gpu' \
  --devices 1 \
  --batch-size 16 \
  --max_epochs 300 \
  --checkpoint-epochs 5 \
  --precision 32 \
  --quality medium \
  --resume_from_checkpoint "${PARTIAL_CHECKPOINT_PATH}"

この時のepochs数は、事前学習時のepoch数 + 追加学習したいepoch数の合計になります

onnxに変換

いつもと同じく変換をします

python3 -m piper_train.export_onnx \
  "${FINETUNED_CKPT_PATH}" \
  "${EXPORT_DIR}/${MODEL_NAME}.onnx"

推論

以下で推論を行います

echo "${TEST_SENTENCE}" | \
  piper \
    -m "${EXPORT_DIR}/${MODEL_NAME}.onnx" \
    --output_file "${OUTPUT_WAV_PATH}"

XPhoneBERTを使って文章から音素列に変換する

初めに

TTSを行う際にg2pを使うことがよくあります。今回はbertを使った音素変換モデルを試します

以下にて試したリポジトリは公開しています。

github.com

開発環境

環境構築

pythonの環境を作り、以下のライブラリをインストールします

pip install torch --index-url https://download.pytorch.org/whl/cu126 transformers>=4.52.4 text2phonemesequence>=0.1.4 tokenizers>=0.21.1

実行

以下のスクリプトを実行します

from transformers import AutoModel, AutoTokenizer
from text2phonemesequence import Text2PhonemeSequence
import torch
import argparse
import sys

def parse_args():
    parser = argparse.ArgumentParser(description='XPhoneBERTを使用してテキストから音素表現を抽出します')
    parser.add_argument('--text', type=str, default="これ は 、 テスト テキスト です .",
                      help='処理するテキスト(単語分割済み)')
    parser.add_argument('--language', type=str, default='jpn',
                      help='言語コード(ISO 639-3)。デフォルトは日本語(jpn)')
    parser.add_argument('--force-cpu', action='store_true',
                      help='GPUが利用可能でも強制的にCPUを使用')
    parser.add_argument('--output-file', type=str,
                      help='特徴量を保存するファイルパス(指定しない場合は保存しない)')
    return parser.parse_args()

def main():
    args = parse_args()
    
    # デバイスの設定
    device = torch.device('cpu' if args.force_cpu or not torch.cuda.is_available() else 'cuda')
    print(f"Using device: {device}")

    try:
        # 1. XPhoneBERTモデルとそのトークナイザをロード
        print("Loading XPhoneBERT model and tokenizer...")
        xphonebert = AutoModel.from_pretrained("vinai/xphonebert-base").to(device)
        tokenizer = AutoTokenizer.from_pretrained("vinai/xphonebert-base")
        print("Model and tokenizer loaded.")

        # 2. Text2PhonemeSequenceをロード
        print(f"Loading Text2PhonemeSequence for {args.language}...")
        text2phone_model = Text2PhonemeSequence(language=args.language, is_cuda=device.type == 'cuda')
        print("Text2PhonemeSequence loaded.")

        # 3. 入力テキストの処理
        print(f"Input text: {args.text}")

        # 4. テキストを音素シーケンスに変換
        print("Converting text to phoneme sequence...")
        input_phonemes = text2phone_model.infer_sentence(args.text)
        print(f"Phoneme sequence: {input_phonemes}")

        # 5. 音素シーケンスをトークナイズ
        print("Tokenizing phoneme sequence...")
        input_ids = tokenizer(input_phonemes, return_tensors="pt").to(device)
        print(f"Input IDs shape: {input_ids['input_ids'].shape}")

        # 6. XPhoneBERTで特徴量抽出
        print("Extracting features with XPhoneBERT...")
        with torch.no_grad():
            features = xphonebert(**input_ids)
        print("Features extracted.")
        print(f"Output features (last hidden state shape): {features.last_hidden_state.shape}")

        # 特徴量の保存(オプション)
        if args.output_file:
            print(f"Saving features to {args.output_file}...")
            torch.save(features.last_hidden_state.cpu(), args.output_file)
            print("Features saved.")

    except Exception as e:
        print(f"Error: {str(e)}", file=sys.stderr)
        sys.exit(1)

if __name__ == "__main__":
    main()

# features.last_hidden_state に音素ごとの表現が含まれます。
# features.pooler_output にはシーケンス全体の集約表現が含まれます(BERTの場合)。

実行後以下のようなログが出力されます

Using device: cuda
Loading XPhoneBERT model and tokenizer...
Some weights of RobertaModel were not initialized from the model checkpoint at vinai/xphonebert-base and are newly initialized: ['pooler.dense.bias', 'pooler.dense.weight']
You should probably TRAIN this model on a down-stream task to be able to use it for predictions and inference.
Model and tokenizer loaded.
Loading Text2PhonemeSequence for jpn...
'wget' は、内部コマンドまたは外部コマンド、
操作可能なプログラムまたはバッチ ファイルとして認識されていません。
Text2PhonemeSequence loaded.
Input text: これ は 、 テスト テキスト です .
Converting text to phoneme sequence...
Phoneme sequence: k o ɾ e ▁ h a ▁ ɕ i ▁ t e s ɯ t o ▁ t e k i s ɯ t o ▁ d e s ɯ ▁ .
Tokenizing phoneme sequence...
Input IDs shape: torch.Size([1, 35])
Extracting features with XPhoneBERT...
Features extracted.
Output features (last hidden state shape): torch.Size([1, 35, 768])

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

LJSpeechを使って英語のpiperの事前学習モデルを作成する

Demo

学習したモデルは以下で公開しています

huggingface.co

生成した音声は以下のようになります

youtu.be

開発環境

環境の構築

まずは学習環境の作成をしていきます。

まずはライブラリをcloneします

git clone https://github.com/rhasspy/piper.git
cd piper/src/python

python以外の環境を準備します

sudo apt-get update
sudo apt-get install -y build-essential
sudo apt-get install -y python3-dev espeak-ng

次に環境を作成します。今回は uvを使ってpythonの環境の仮想環境を作ります

cd src/python
uv venv -p 3.11
source .venv/bin/activate

必要なライブラリをインストールします。

uv pip install --upgrade pip wheel setuptools
uv pip install -e . 
uv pip install pytorch-lightning
uv pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
./build_monotonic_align.sh

データセットの準備

リポジトリがあるルートフォルダと同じところで、データセットのフォルダを作ってデータセットをダウンロードしていきます

mkdir -p ~/datasets
cd ~/datasets
wget https://data.keithito.com/data/speech/LJSpeech-1.1.tar.bz2
# 解凍
tar -xjvf LJSpeech-1.1.tar.bz2

このデータセットのパスを環境パスに保存しておきます

export INPUT_DATASET_DIR="your path/datasets/LJSpeech-1.1"

前処理・ログ用のフォルダの作成

前処理済みデータと学習ログを保存するディレクトリを作成します。

export TRAINING_DATA_DIR="your path/piper_ljspeech_training"
mkdir -p $TRAINING_DATA_DIR

前処理の実行

先ほど設定した環境変数を使い データセットの前処理を行っていきます

python3 -m piper_train.preprocess \
  --language en-us \
  --input-dir ${INPUT_DATASET_DIR} \
  --output-dir ${TRAINING_DATA_DIR} \
  --dataset-format ljspeech \
  --single-speaker \
  --sample-rate 22050

だいたい1時間くらいでした(スペックによります)

完了すると以下のようなログになります

ファイルによっては途中で失敗することがあります。その際には以下のような 処理済みのファイルから未処理のデータセット一覧を作成して前処理のみを再開することができます

import csv
import os
import json
import sys
from tqdm import tqdm

def create_resume_file(original_metadata_path, preprocessed_dir_path, resume_output_path):
    """
    前処理が中断した箇所から再開するための新しいメタデータファイルを作成します。
    """
    
    # 1. 既に処理済みのファイルのリストを作成する
    processed_audio_paths = set()
    partial_dataset_jsonl = os.path.join(preprocessed_dir_path, "dataset.jsonl")
    
    if not os.path.exists(partial_dataset_jsonl):
        print(f"Warning: Partial 'dataset.jsonl' not found at {partial_dataset_jsonl}. Assuming no files were processed.")
    else:
        print(f"Reading already processed files from {partial_dataset_jsonl}...")
        with open(partial_dataset_jsonl, 'r', encoding='utf-8') as f:
            for line in f:
                try:
                    data = json.loads(line)
                    if "audio_path" in data:
                        processed_audio_paths.add(data["audio_path"])
                except json.JSONDecodeError:
                    print(f"Warning: Could not decode JSON line: {line.strip()}", file=sys.stderr)
        print(f"Found {len(processed_audio_paths)} processed files.")

    # 2. 元のメタデータを読み込み、未処理の行だけを新しいファイルに書き出す
    print(f"Reading original metadata from {original_metadata_path} to find unprocessed files...")
    unprocessed_rows = []
    
    original_dataset_dir = os.path.dirname(original_metadata_path)
    
    with open(original_metadata_path, 'r', encoding='utf-8') as f:
        reader = csv.reader(f, delimiter='|')
        all_rows = list(reader)
        for row in tqdm(all_rows, desc="Comparing metadata"):
            if not row or len(row) < 1:
                continue
            
            file_id = row[0]
            # dataset.jsonlに記録されているフルパスと一致させる
            # piper_train.preprocess は入力ディレクトリからの相対パスではなく、絶対パスを記録することがあるため、
            # os.path.join で結合してフルパスを作成します。
            expected_audio_path = os.path.join(original_dataset_dir, "wavs", f"{file_id}.wav")

            if expected_audio_path not in processed_audio_paths:
                unprocessed_rows.append(row)

    print(f"Found {len(unprocessed_rows)} unprocessed files.")
    
    # 3. 未処理の行を新しいメタデータファイルに保存
    if unprocessed_rows:
        print(f"Saving resume metadata to {resume_output_path}...")
        with open(resume_output_path, 'w', encoding='utf-8', newline='') as f:
            writer = csv.writer(f, delimiter='|')
            writer.writerows(unprocessed_rows)
        print("Resume file created successfully.")
    else:
        print("No unprocessed files found. Preprocessing may have completed or an error occurred before any processing.")

if __name__ == "__main__":
    # --- ★設定箇所★ ---
    
    # Piper学習用に準備した元のデータセットのディレクトリ
    # (`metadata.csv` と `wavs/` がある場所)
    original_dataset_dir = "/data/moe-speech-plus-ljspeech" # お客様が --input-dir で指定したパス
    
    # 前処理が中断した出力先ディレクトリ
    # (中に部分的な `dataset.jsonl` がある場所)
    preprocessed_dir = "/data/piper_moe-speech-plus_preprocessed_single" # お客様が --output-dir で指定したパス

    # --- 設定ここまで ---
    
    original_metadata = os.path.join(original_dataset_dir, "metadata.csv")
    resume_metadata = os.path.join(original_dataset_dir, "metadata_resume.csv") # 新しく作成するファイル

    create_resume_file(original_metadata, preprocessed_dir, resume_metadata)

これを実行することで、metadata_resume.csv が作成されます。

このファイルをmetadata.csvに名前を変更して再度前処理を実行します

INFO:preprocess:Single speaker dataset

INFO:preprocess:Wrote dataset config

INFO:preprocess:Processing 13100 utterance(s) with 48 worker(s)

事前学習の開始

以下のコマンドにて事前学習を行います

python -m piper_train \
  --dataset-dir ${TRAINING_DATA_DIR} \
  --accelerator 'gpu' \
  --devices 1 \
  --batch-size 24 \
  --validation-split 0.05 \
  --num-test-examples 10 \
  --max_epochs 5000 \
  --checkpoint-epochs 1 \
  --precision 32 \
  --quality medium

調べた感じマルチGPUは対応していないみたいだったので、仕方なくシングルGPUで学習を行っています

学習されたモデルは以下のパスに保存されています

piper_ljspeech_training/lightning_logs/version_X/checkpoints/

学習後のlossは以下のようになりました

モデルをonnxに変換

これを推論するためにonnxに変換します

python3 -m piper_train.export_onnx \
  /data/piper_ljspeech_training/lightning_logs/version_1/checkpoints/epoch=499-step=519000.ckpt \
  ~/piper_MODELS_EXPORTED/my_ljspeech_piper_voice.onnx

この時にモデルの設定ファイルもコピーしておきます

cp /data/piper_ljspeech_training/config.json \
  ~/piper_MODELS_EXPORTED/my_ljspeech_piper_voice.onnx.json

学習したモデルから推論

onnxに変換したモデルから実際に音声を生成してみます 英語のみの音声データのため、英語のテキストを使って音声合成を行います。

echo "Hello, this is a test of the trained Piper model." | \
  piper \
    -m ./${ONNX_MODEL_NAME}.onnx \
    --output_file output.wav

PythonでXのLive Search APIを実行して検索してみる

初めに

先日 Live Search APIが使えるようになったので、こちらを動かしてみます

開発環境

セットアップ

簡単にPythonを動かす環境を作ります

必要なライブラリをインストールします。

pip install requests  

APIを実行する

以下のコードでLive Search APIを実行できます

import requests

url = "https://api.x.ai/v1/chat/completions"
headers = {
    "Content-Type": "application/json",
    "Authorization": f"Bearer {API_KEY}"
}
payload = {
    "messages": [
        {
            "role": "user",
            "content": "Provide me a digest of world news in the last 24 hours."
        }
    ],
    "search_parameters": {
        "mode": "auto"
    },
    "model": "grok-3-latest"
}

response = requests.post(url, headers=headers, json=payload)
print(response.json())

結果は以下のようになります

{'id': 'e2eb631c-7455-4b06-913c-8e515f338b72', 'object': 'chat.completion', 'created': 1748069021, 'model': 'grok-3', 'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': "Here is a digest of world news based on information available from recent online sources and discussions on social media platforms like X, covering the last 24 hours as of May 24, 2025. Since real-time data for the exact last 24 hours may be limited or incomplete in the provided context, I’ve focused on the most recent and relevant updates while noting that some information may be based on posts or reports from slightly earlier in the week. For the most current and verified details, I recommend checking trusted news outlets directly.\n\n1. **United States - Political Developments**: \n   - Recent discussions on X highlight President Donald Trump's active role in international diplomacy, with mentions of efforts toward an India-Pakistan ceasefire, facilitating a meeting between Zelensky and Putin, and securing a trade deal with China. These claims, while widely discussed on social media, lack specific confirmation from official sources within the last 24 hours (based on posts dated May 11 and earlier on X). However, they reflect ongoing public interest in Trump's foreign policy moves.\n\n2. **Middle East - Gaza and Israel**:\n   - Tensions in Gaza remain high, with recent reports from earlier in the week (May 19-20) indicating Israeli military actions and international responses. Posts on X from May 19 mention renewed ceasefire talks between Hamas and Israel in Qatar, as well as ongoing concerns about aid delivery in Gaza. While no specific updates from the last 24 hours are detailed in the provided data, the situation continues to be a focal point of global concern, with earlier BBC reports (May 20) noting casualties from Israeli strikes and international calls for de-escalation from countries like the UK, France, and Canada (CNN, May 19).\n\n3. **Ukraine-Russia Conflict**:\n   - The war in Ukraine continues to dominate headlines, though specific updates from the last 24 hours are not detailed in the latest data. Earlier reports from May 20 (BBC and CNN) discuss a phone call between Trump and Putin, with differing perspectives on peace talks. Ukrainian President Zelensky has accused Russia of stalling negotiations, while Trump has suggested immediate talks. Sentiment on X reflects ongoing global attention to this conflict, though no new developments are confirmed for the past day.\n\n4. **Other Global Highlights**:\n   - Posts on X from the past week mention various international events, though not specifically within the last 24 hours. These include military drills by China near Australia, political instability in South Korea and Romania, and unrest in Syria. While these topics indicate a broad range of global issues, they are not timestamped to the immediate past day and should be treated as background context rather than current news.\n   - General news sources like Voice of America (VOA) continue to provide coverage of global events, with updates as recent as 8 hours ago (as of May 24), though specific stories are not detailed in the provided information.\n\n**Note**: Due to the limitations of the data provided, some of the digest includes context from slightly older reports (up to May 22-23 on X and May 20-21 in news articles). Real-time news within the exact last 24 hours (May 23-24, 2025) may not be fully captured here. Sentiment and topics trending on X suggest continued focus on geopolitical conflicts and U.S. political actions, but these should be verified with primary news outlets like BBC, CNN, or VOA for the latest developments.\n\nIf you’re looking for more specific or breaking news from the last few hours, I suggest visiting a trusted news website or app for the most up-to-date information. Is there a particular region or topic you’d like me to dive deeper into?", 'refusal': None}, 'finish_reason': 'stop'}], 'usage': {'promon': 'stop'}], 'usage': {'prompt_tokens': 2364, 'completion_tokens': 749, 'total_tokens': 3113, 'prompt_tokens_details': {'text_tokens': 2364, 'audiatus/1atus/1848065510476587353', 'https://x.com/GoodShepherd316/status/1865243223938998382', 'https://x.com/scottmelker/status/1921628456590221692']}

python-audio-separatorで歌声のボーカルを抽出する

初めに

以下で歌声の音源からボーカルを抽出していました

ayousanz.hatenadiary.jp

改めて調査するとより多くのモデルと精度の高いモデルが選べるのものがあり、ローカル環境でも動くようになっていたので触っていきます

github.com

開発環境

環境構築

uv venv -p 3.12
.venv/Scripts/activate

必要なライブラリのインストールします

uv pip install "audio-separator[gpu]"
uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu124 --force-reinstall

使用できるモデルの取得

使用できるモデルの一覧の表示やボーカルに特化した精度の高いものをCLIから取得できます。

以下にて、ボーカルの抽出において精度の高いものを取得することができます

audio-separator -l --list_filter=vocals --list_limit=5

こちらを実行すると以下のような情報が得られます

2025-05-17 17:32:15,454 - INFO - separator - Using model directory from model_file_dir parameter: /tmp/audio-separator-models/
-----------------------------------------------------------------------------------------------------------------------------------------
Model Filename                      Arch  Output Stems (SDR)     Friendly Name
-----------------------------------------------------------------------------------------------------------------------------------------
vocals_mel_band_roformer.ckpt       MDXC  vocals* (12.6), other  Roformer Model: MelBand Roformer | Vocals by Kimberley Jensen
melband_roformer_big_beta4.ckpt     MDXC  vocals* (12.5), other  Roformer Model: MelBand Roformer Kim | Big Beta 4 FT by unwa
mel_band_roformer_kim_ft_unwa.ckpt  MDXC  vocals* (12.4), other  Roformer Model: MelBand Roformer Kim | FT by unwa
melband_roformer_big_beta5e.ckpt    MDXC  vocals* (12.4), other  Roformer Model: MelBand Roformer Kim | Big Beta 5e FT by unwa
MelBandRoformerBigSYHFTV1.ckpt      MDXC  vocals* (12.3), other  Roformer Model: MelBand Roformer Kim | Big SYHFT V1 by SYH99999

ただし今回は 上記のものよりもさらに精度が高いと言われている mel_band_roformer_karaoke_becruily.ckpt を使っていきます

CLIからボーカルを抽出

以下にて指定した音源からボーカルを抽出およびボーカルの音声のみを出力することができます

audio-separator --model_filename mel_band_roformer_karaoke_becruily.ckpt --output_format=MP3 --single_stem=Vocals 'test.mp3'

Pythonから実行

以下のコードにてPythonスクリプトから処理することができます

from audio_separator.separator import Separator
import logging

# --- 設定項目 ---
input_audio_file = r'.\test.mp3'
model_name = 'mel_band_roformer_karaoke_becruily.ckpt'
output_audio_format = 'MP3'
stem_to_output = 'Vocals'

# Separatorクラスを初期化
separator = Separator(
    output_format=output_audio_format,
    output_single_stem=stem_to_output,
    log_level=logging.INFO
)

# モデルをロード
try:
    separator.load_model(model_filename=model_name)
    print(f"モデル '{model_name}' のロードに成功しました。")
except Exception as e:
    print(f"モデルのロード中にエラーが発生しました: {e}")
    exit()

# オーディオ分離処理を実行
print(f"オーディオファイル '{input_audio_file}' の分離を開始します...")
try:
    output_files = separator.separate(input_audio_file)
    if output_files:
        print(f"分離が完了しました。出力ファイル:")
        for file_path in output_files:
            print(f"- {file_path}")
    else:
        print("分離処理は実行されましたが、出力ファイルはありませんでした。")
except Exception as e:
    print(f"オーディオ分離処理中にエラーが発生しました: {e}")

k-washi/speaker-emb-ja-ecapa-tdnnを使って似ている歌声ボーカルを探す

初めに

以下の記事で Resemblyzerを使って似ている歌声を探してみました。今回は 別のモデルを使ってみます

ayousanz.hatenadiary.jp

開発環境

環境構築

uv venv .venv -p 3.12
.venv/Scripts/activate  # Windowsの場合
uv pip install speechbrain torch torchaudio librosa numpy scikit-learn gdown
uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu121 --forc

音声データの取得

以下の記事を参考にしてください。

流れは以下になります 1. youtube等から音源の取得 2. uvr等でボーカルのみを抽出

ayousanz.hatenadiary.jp

ayousanz.hatenadiary.jp

speaker-emb-ja-ecapa-tdnnを使って音声類似度比較を行う

以下を実行して似ているボーカルを探します

import torchaudio
from pathlib import Path
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
import torch
import torch.nn.functional as F

def get_embedding_speechbrain(audio_path_str, model):
    """指定された音声ファイルからECAPA-TDNNを使って埋め込みを抽出する"""
    fpath = Path(audio_path_str)
    if not fpath.exists():
        print(f"エラー: 音声ファイルが見つかりません - {fpath}")
        return None

    try:
        wave, sr = torchaudio.load(fpath)
        # モデルのサンプルレートにリサンプリング
        if sr != model.sample_rate:
            wave = torchaudio.transforms.Resample(sr, model.sample_rate)(wave)
        # 埋め込み抽出
        with torch.no_grad():
            emb = model.extract_embedding(wave)
            emb = F.normalize(torch.FloatTensor(emb), p=2, dim=1).detach().cpu()
        return emb.squeeze().numpy()
    except Exception as e:
        print(f"エラー: {fpath} の処理中にエラーが発生しました: {e}")
        import traceback
        traceback.print_exc()
        return None

def main():
    try:
        print("ECAPA-TDNNモデルをロード中... (初回は時間がかかることがあります)")
        model = torch.hub.load("k-washi/speaker-emb-ja-ecapa-tdnn", "ecapatdnn_ja_l512_va", trust_repo=True, pretrained=True)
        # モデルを推論モードに設定
        model.model.eval()
        print("モデルのロード完了。")
    except Exception as e:
        print(f"モデルの初期化に失敗しました: {e}")
        print("モデル名が正しいか、インターネット接続、torchのバージョンなどを確認してください。")
        import traceback
        traceback.print_exc()
        return

    # --- 音声ファイルのパスを指定 ---
    target_audio_path = Path("vocal_target.wav")
    candidate_audio_paths = [
        Path("data/vocal_test.wav") ]

    print(f"ターゲット音声: {target_audio_path}")
    print("候補音声リスト:")
    for p in candidate_audio_paths:
        print(f"- {p}")
    print("-" * 30)


    # ターゲット音声の埋め込みを抽出
    target_embedding = get_embedding_speechbrain(target_audio_path, model)

    if target_embedding is None:
        print("ターゲット音声の埋め込みが抽出できませんでした。処理を終了します。")
        return

    # 候補音声の埋め込みを抽出し、類似度を計算
    similarities = []
    for cand_path in candidate_audio_paths:
        print(f"\n候補音声 {cand_path} の処理中...")
        cand_embedding = get_embedding_speechbrain(cand_path, model)
        if cand_embedding is not None:
            similarity = cosine_similarity(target_embedding.reshape(1, -1), cand_embedding.reshape(1, -1))[0][0]
            similarities.append((str(cand_path), similarity))
            print(f"  類似度: {similarity:.4f}")
        else:
            print(f"  {cand_path} の埋め込み抽出に失敗しました。")


    similarities.sort(key=lambda x: x[1], reverse=True)

    print("\n--- 類似度ランキング (k-washi/speaker-emb-ja-ecapa-tdnn) ---")
    for path_str, score in similarities:
        print(f"{path_str}: {score:.4f}")

    if similarities:
        print(f"\n最も近いと思われる音声: {similarities[0][0]} (類似度: {similarities[0][1]:.4f})")
    else:
        print("\n類似度を計算できる候補がありませんでした。")

if __name__ == "__main__":
    main()

実行すると以下になります

最も近いと思われる音声: data\vocal_target.wav (類似度: 0.8525)