VITS2モデルの構造をモデルとconfig.jsonをロードして確認する

開発環境

モデルの構造確認

以下のコードでモデルの構造を確認できます

import torch
from pathlib import Path
import json
from safetensors import safe_open
import argparse


def load_model(model_path):
    if model_path.suffix == '.safetensors':
        with safe_open(model_path, framework="pt", device="cpu") as f:
            return {key: f.get_tensor(key) for key in f.keys()}
    elif model_path.suffix == '.pth':
        return torch.load(model_path, map_location='cpu')
    else:
        raise ValueError(f"Unsupported file format: {model_path.suffix}")


def analyze_model_structure(model_dict):
    sizes = set()
    important_shapes = {}
    for name, param in model_dict.items():
        if isinstance(param, torch.Tensor):
            if len(param.shape) > 0:
                sizes.add(param.shape[-1])
                if param.shape[-1] in [256, 512]:
                    important_shapes[name] = param.shape
    return sizes, important_shapes


def analyze_models(model_paths, config_path):
    config_path = Path(config_path)

    # configファイルを読み込む
    with open(config_path, 'r') as f:
        config = json.load(f)

    all_sizes = set()
    all_important_shapes = {}

    for model_path in model_paths:
        model_path = Path(model_path)
        print(f"\nAnalyzing {model_path.name}:")

        model_dict = load_model(model_path)
        if 'model' in model_dict:
            model_dict = model_dict['model']

        sizes, important_shapes = analyze_model_structure(model_dict)
        all_sizes.update(sizes)
        all_important_shapes.update(important_shapes)

        print(f"Unique sizes found: {sorted(sizes)}")
        print("Important shapes (256 or 512):")
        for name, shape in important_shapes.items():
            print(f"  {name}: shape = {shape}")

    print("\nOverall summary:")
    print(f"All unique sizes found across models: {sorted(all_sizes)}")

    print("\nImportant config information:")
    print(f"Model name: {config.get('model_name', 'Not specified')}")
    print(f"Version: {config.get('version', 'Not specified')}")
    print(f"Gin channels: {config['model'].get('gin_channels', 'Not specified')}")
    print(f"Hidden channels: {config['model'].get('hidden_channels', 'Not specified')}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Analyze VITS2 model files")
    parser.add_argument("config_path", type=str, help="Path to the config.json file")
    parser.add_argument("model_paths", type=str, nargs='+', help="Paths to the model files (.pth or .safetensors)")

    args = parser.parse_args()

    analyze_models(args.model_paths, args.config_path)

以下のようにモデルの構造を確認することができます

python .\analyze_vits2_model_structure.py .\model_assets\test\config.json  .\model_assets\test\test_e1000_s25000.safetensors

Analyzing test_e1000_s25000.safetensors:
Unique sizes found: [1, 2, 3, 5, 7, 8, 11, 16, 29, 32, 64, 96, 128, 192, 256, 384, 512, 768]
Important shapes (256 or 512):
  dec.cond.bias: shape = torch.Size([512])
  dec.conv_pre.bias: shape = torch.Size([512])
  dec.resblocks.0.convs1.0.bias: shape = torch.Size([256])
  dec.resblocks.0.convs1.1.bias: shape = torch.Size([256])
  dec.resblocks.0.convs1.2.bias: shape = torch.Size([256])
  dec.resblocks.0.convs2.0.bias: shape = torch.Size([256])
  dec.resblocks.0.convs2.1.bias: shape = torch.Size([256])
  dec.resblocks.0.convs2.2.bias: shape = torch.Size([256])
  dec.resblocks.1.convs1.0.bias: shape = torch.Size([256])
  dec.resblocks.1.convs1.1.bias: shape = torch.Size([256])
  dec.resblocks.1.convs1.2.bias: shape = torch.Size([256])
  dec.resblocks.1.convs2.0.bias: shape = torch.Size([256])
  dec.resblocks.1.convs2.1.bias: shape = torch.Size([256])
  dec.resblocks.1.convs2.2.bias: shape = torch.Size([256])
  dec.resblocks.2.convs1.0.bias: shape = torch.Size([256])
  dec.resblocks.2.convs1.1.bias: shape = torch.Size([256])
  dec.resblocks.2.convs1.2.bias: shape = torch.Size([256])
  dec.resblocks.2.convs2.0.bias: shape = torch.Size([256])
  dec.resblocks.2.convs2.1.bias: shape = torch.Size([256])
  dec.resblocks.2.convs2.2.bias: shape = torch.Size([256])
  dec.ups.0.bias: shape = torch.Size([256])
  dp.conv_1.bias: shape = torch.Size([256])
  dp.conv_2.bias: shape = torch.Size([256])
  dp.norm_1.beta: shape = torch.Size([256])
  dp.norm_1.gamma: shape = torch.Size([256])
  dp.norm_2.beta: shape = torch.Size([256])
  dp.norm_2.gamma: shape = torch.Size([256])
  emb_g.weight: shape = torch.Size([1, 512])
  enc_p.encoder.spk_emb_linear.weight: shape = torch.Size([192, 512])
  enc_p.style_proj.weight: shape = torch.Size([192, 256])
  flow.flows.0.enc.spk_emb_linear.weight: shape = torch.Size([192, 512])
  flow.flows.2.enc.spk_emb_linear.weight: shape = torch.Size([192, 512])
  flow.flows.4.enc.spk_emb_linear.weight: shape = torch.Size([192, 512])
  flow.flows.6.enc.spk_emb_linear.weight: shape = torch.Size([192, 512])

Overall summary:
All unique sizes found across models: [1, 2, 3, 5, 7, 8, 11, 16, 29, 32, 64, 96, 128, 192, 256, 384, 512, 768]

Important config information:
Model name: nadeko
Version: 2.4.1-JP-Extra
Gin channels: 512
Hidden channels: 192

stable-audio-toolsでstabilityai/stable-audio-open-1.0の推論を行う

開発環境

  • Windows11
  • python 3.11
  • 4070 ti super

準備

まずは stable-audio-toolsをcloneします

次に依存周りをインストールしていきます

pip install .

このままだとcudaが認識されないので、cudaに対応したライブラリのインストールを行います

pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121

モデルのダウンロードのために huggingfaceにログインを行います (read権限だけで大丈夫です)

huggingface-cli login

実行

以下で実行することができます

python ./run_gradio.py --pretrained-name stabilityai/stable-audio-open-1.0

プロンプトに生成したい音声の情報を入れることで実行することができます

備考

Google Colob(L4)で実行すると以下のエラーでインストールが進みませんでした

  Building editable for stable-audio-tools (pyproject.toml) ... done
  Created wheel for stable-audio-tools: filename=stable_audio_tools-0.0.16-0.editable-py3-none-any.whl size=4116 sha256=acea626d79508289c34cf0c48f52ea72b2c1bdd04b957dee333284faaf722f5b
  Stored in directory: /tmp/pip-ephem-wheel-cache-axzo8rji/wheels/ec/b0/ad/af15732c5c021a13bcb6f3df8110ac75670c0cad6050ee76b3
Successfully built stable-audio-tools
Installing collected packages: argparse, stable-audio-tools
  Attempting uninstall: stable-audio-tools
    Found existing installation: stable-audio-tools 0.0.16
    Uninstalling stable-audio-tools-0.0.16:
      Successfully uninstalled stable-audio-tools-0.0.16
Successfully installed argparse-1.4.0 stable-audio-tools-0.0.16
WARNING: The following packages were previously imported in this runtime:
  [argparse]
You must restart the runtime in order to use newly installed versions.

pyannote.audioのInferenceの処理でDetails: choose a window size 400 that is [2, 251]のエラーが出る場合の対応

初めに

開発環境

詳細

pyannote.audioのInferenceの処理で以下のエラーが出ることがあります。

Details:
choose a window size 400 that is [2, 251]

こちらは windowが"whole"になっていることで、"whole" オプションを使用する場合システムは音声ファイル全体を一つのウィンドウとして扱おうとします。この特定のファイルが非常に短い、または非常に長い可能性があります。

対方方法として window = "sliding"にします

inference = Inference(model, window="sliding", duration=5.0)

pyworldを使った音声モーフィングの実装

初めに

TTSをしている中で特定の音声同士を合わせた音声が欲しい時があります。TTSではマージがありますが、マージとは違うアプローチを考えていきます

VOICEVOXにはモーフィングが実装されているみたいなので、実装コードを眺めてみます。

VOICEVOX Engineの以下がモーフィング部分ですが、pyworldを使用していることがわかります。

github.com

こちらを実際に触っていきます

Demo

以下が各内容の内容が入っているリポジトリになります

github.com

開発環境

実装のアプローチ

  1. クロスフェード
  2. DTWを使った実装
  3. DTW + numbaを使った高速化

クロスフェード

まずは、二つの音声をクロスフェードを使って実装する方法を試していきます

import numpy as np
import pyworld as pw
import soundfile as sf
from soxr import resample


def align_waves(wav1, wav2):
    # 長い方の波形を短い方に合わせる
    if len(wav1) > len(wav2):
        wav1 = wav1[:len(wav2)]
    else:
        wav2 = wav2[:len(wav1)]
    return wav1, wav2


def synthesis_morphing_parameter(wav1, wav2, fs):
    frame_period = 5.0

    # 波形の長さを合わせる
    wav1, wav2 = align_waves(wav1, wav2)

    f0_1, time_axis_1 = pw.harvest(wav1, fs, frame_period=frame_period)
    sp_1 = pw.cheaptrick(wav1, f0_1, time_axis_1, fs)
    ap_1 = pw.d4c(wav1, f0_1, time_axis_1, fs)

    f0_2, time_axis_2 = pw.harvest(wav2, fs, frame_period=frame_period)
    sp_2 = pw.cheaptrick(wav2, f0_2, time_axis_2, fs)
    ap_2 = pw.d4c(wav2, f0_2, time_axis_2, fs)

    return f0_1, sp_1, ap_1, f0_2, sp_2, ap_2, fs, frame_period


def synthesize_morphed_wave(f0_1, sp_1, ap_1, f0_2, sp_2, ap_2, fs, frame_period, morph_rate):
    if morph_rate < 0.0 or morph_rate > 1.0:
        raise ValueError("morph_rateは0.0から1.0の範囲で指定してください")

    f0_morph = f0_1 * (1.0 - morph_rate) + f0_2 * morph_rate
    sp_morph = sp_1 * (1.0 - morph_rate) + sp_2 * morph_rate
    ap_morph = ap_1 * (1.0 - morph_rate) + ap_2 * morph_rate

    y_h = pw.synthesize(f0_morph, sp_morph, ap_morph, fs, frame_period)

    return y_h.astype(np.float32)


# メイン処理
wav1, fs = sf.read('voice1.wav')
wav2, fs = sf.read('voice2.wav')

# パラメータ抽出
f0_1, sp_1, ap_1, f0_2, sp_2, ap_2, fs, frame_period = synthesis_morphing_parameter(wav1, wav2, fs)

# モーフィング率(0.0から1.0の間)
morph_rate = 0.5

# モーフィングした音声を合成
morphed_wave = synthesize_morphed_wave(f0_1, sp_1, ap_1, f0_2, sp_2, ap_2, fs, frame_period, morph_rate)

# 合成した音声を保存
sf.write('morphed_voice.wav', morphed_wave, fs)

# オリジナルの音声も保存(比較用)
wav1, wav2 = align_waves(wav1, wav2)
sf.write('original_voice1.wav', wav1, fs)
sf.write('original_voice2.wav', wav2, fs)

モーフィング?にはなりますが、思ったものとは違うため別の方法を考えていきます

DTWを使ったモーフィング

二つの音声をモーフィングする際に、それぞれの音声の長さが違う場合に音がずれてしまうためその部分の対応をしていきます

import numpy as np
import pyworld as pw
import soundfile as sf
from dtaidistance import dtw


def align_with_dtw(wav1, wav2):
    # DTWで最適なパスを見つける
    path = dtw.warping_path(wav1, wav2)


    # パスに基づいて wav2 を伸縮する
    wav2_warped = np.zeros_like(wav1)
    for i, j in path:
        wav2_warped[i] = wav2[j]

    return wav1, wav2_warped


def synthesis_morphing_parameter(wav1, wav2, fs):
    frame_period = 5.0

    # DTWで波形を揃える
    wav1, wav2 = align_with_dtw(wav1, wav2)

    f0_1, time_axis_1 = pw.harvest(wav1, fs, frame_period=frame_period)
    sp_1 = pw.cheaptrick(wav1, f0_1, time_axis_1, fs)
    ap_1 = pw.d4c(wav1, f0_1, time_axis_1, fs)

    f0_2, time_axis_2 = pw.harvest(wav2, fs, frame_period=frame_period)
    sp_2 = pw.cheaptrick(wav2, f0_2, time_axis_2, fs)
    ap_2 = pw.d4c(wav2, f0_2, time_axis_2, fs)

    # パラメータの長さを揃える
    min_len = min(len(f0_1), len(f0_2))
    f0_1, sp_1, ap_1 = f0_1[:min_len], sp_1[:min_len], ap_1[:min_len]
    f0_2, sp_2, ap_2 = f0_2[:min_len], sp_2[:min_len], ap_2[:min_len]

    return f0_1, sp_1, ap_1, f0_2, sp_2, ap_2, fs, frame_period


def synthesize_morphed_wave(f0_1, sp_1, ap_1, f0_2, sp_2, ap_2, fs, frame_period, morph_rate):
    if morph_rate < 0.0 or morph_rate > 1.0:
        raise ValueError("morph_rateは0.0から1.0の範囲で指定してください")

    f0_morph = f0_1 * (1.0 - morph_rate) + f0_2 * morph_rate
    sp_morph = sp_1 * (1.0 - morph_rate) + sp_2 * morph_rate
    ap_morph = ap_1 * (1.0 - morph_rate) + ap_2 * morph_rate

    y_h = pw.synthesize(f0_morph, sp_morph, ap_morph, fs, frame_period)

    return y_h.astype(np.float32)


# メイン処理
wav1, fs = sf.read('voice1.wav')
wav2, fs = sf.read('voice2.wav')

# パラメータ抽出
f0_1, sp_1, ap_1, f0_2, sp_2, ap_2, fs, frame_period = synthesis_morphing_parameter(wav1, wav2, fs)

# モーフィング率(0.0から1.0の間)
morph_rate = 0.5

# モーフィングした音声を合成
morphed_wave = synthesize_morphed_wave(f0_1, sp_1, ap_1, f0_2, sp_2, ap_2, fs, frame_period, morph_rate)

# 合成した音声を保存
sf.write('morphed_voice.wav', morphed_wave, fs)

# オリジナルの音声も保存(比較用)
wav1, wav2 = align_with_dtw(wav1, wav2)
sf.write('original_voice1.wav', wav1, fs)
sf.write('original_voice2.wav', wav2, fs)

こちらにて二つの音声がモーフィングされるようになりました。ただDTWの処理がかなり遅いので、改善の余地があります

DTW + numbaにて高速化

DTW + numpyだと処理に時間がかかるため、numpyの代わりにnumbaを使った高速化を行います。

import numpy as np
import pyworld as pw
import soundfile as sf
from scipy.signal import resample
import numba


@numba.jit(nopython=True, parallel=True)
def dtw_distance(x, y):
    n, m = len(x), len(y)
    dtw_matrix = np.zeros((n + 1, m + 1))
    dtw_matrix[0, 1:] = np.inf
    dtw_matrix[1:, 0] = np.inf

    for i in numba.prange(1, n + 1):
        for j in range(1, m + 1):
            cost = abs(x[i - 1] - y[j - 1])
            dtw_matrix[i, j] = cost + min(dtw_matrix[i - 1, j], dtw_matrix[i, j - 1], dtw_matrix[i - 1, j - 1])

    return dtw_matrix


@numba.jit(nopython=True)
def get_path(dtw_matrix):
    n, m = dtw_matrix.shape
    path = []
    i, j = n - 1, m - 1
    while i > 0 and j > 0:
        path.append((i - 1, j - 1))
        if i == 1:
            j -= 1
        elif j == 1:
            i -= 1
        else:
            if dtw_matrix[i - 1, j] == min(dtw_matrix[i - 1, j - 1], dtw_matrix[i - 1, j], dtw_matrix[i, j - 1]):
                i -= 1
            elif dtw_matrix[i, j - 1] == min(dtw_matrix[i - 1, j - 1], dtw_matrix[i - 1, j], dtw_matrix[i, j - 1]):
                j -= 1
            else:
                i -= 1
                j -= 1
    path.reverse()
    return path


def align_with_dtw(wav1, wav2):
    print(f"wav1 shape: {wav1.shape}, wav2 shape: {wav2.shape}")

    # リサンプリングして長さを揃える
    target_length = max(len(wav1), len(wav2))
    wav1 = resample(wav1, target_length)
    wav2 = resample(wav2, target_length)

    # DTWを使用
    dtw_matrix = dtw_distance(wav1, wav2)
    path = get_path(dtw_matrix)

    # パスに基づいて wav2 を伸縮する
    wav2_warped = np.zeros_like(wav1)
    for i, j in path:
        wav2_warped[i] = wav2[j]

    return wav1, wav2_warped


def synthesis_morphing_parameter(wav1, wav2, fs):
    frame_period = 5.0

    # DTWで波形を揃える
    wav1, wav2 = align_with_dtw(wav1, wav2)

    f0_1, time_axis_1 = pw.harvest(wav1, fs, frame_period=frame_period)
    sp_1 = pw.cheaptrick(wav1, f0_1, time_axis_1, fs)
    ap_1 = pw.d4c(wav1, f0_1, time_axis_1, fs)

    f0_2, time_axis_2 = pw.harvest(wav2, fs, frame_period=frame_period)
    sp_2 = pw.cheaptrick(wav2, f0_2, time_axis_2, fs)
    ap_2 = pw.d4c(wav2, f0_2, time_axis_2, fs)

    # パラメータの長さを揃える
    min_len = min(len(f0_1), len(f0_2))
    f0_1, sp_1, ap_1 = f0_1[:min_len], sp_1[:min_len], ap_1[:min_len]
    f0_2, sp_2, ap_2 = f0_2[:min_len], sp_2[:min_len], ap_2[:min_len]

    return f0_1, sp_1, ap_1, f0_2, sp_2, ap_2, fs, frame_period


def synthesize_morphed_wave(f0_1, sp_1, ap_1, f0_2, sp_2, ap_2, fs, frame_period, morph_rate):
    if morph_rate < 0.0 or morph_rate > 1.0:
        raise ValueError("morph_rateは0.0から1.0の範囲で指定してください")

    f0_morph = f0_1 * (1.0 - morph_rate) + f0_2 * morph_rate
    sp_morph = sp_1 * (1.0 - morph_rate) + sp_2 * morph_rate
    ap_morph = ap_1 * (1.0 - morph_rate) + ap_2 * morph_rate

    y_h = pw.synthesize(f0_morph, sp_morph, ap_morph, fs, frame_period)

    return y_h.astype(np.float32)


# メイン処理
wav1, fs = sf.read('voice1.wav')
wav2, fs = sf.read('voice2.wav')

print(f"Original wav1 shape: {wav1.shape}, wav2 shape: {wav2.shape}")

f0_1, sp_1, ap_1, f0_2, sp_2, ap_2, fs, frame_period = synthesis_morphing_parameter(wav1, wav2, fs)

morph_rate = 0.5

morphed_wave = synthesize_morphed_wave(f0_1, sp_1, ap_1, f0_2, sp_2, ap_2, fs, frame_period, morph_rate)

sf.write('morphed_voice.wav', morphed_wave, fs)

wav1, wav2 = align_with_dtw(wav1, wav2)
sf.write('original_voice1.wav', wav1, fs)
sf.write('original_voice2.wav', wav2, fs)

以下が実行時間です。

Original wav1 shape: (70920,), wav2 shape: (60696,)
wav1 shape: (70920,), wav2 shape: (60696,)
DTW alignment time: 19.39 seconds
Parameter extraction time: 0.57 seconds
Parameter morphing time: 0.00 seconds
Waveform synthesis time: 0.04 seconds
wav1 shape: (70920,), wav2 shape: (60696,)
Total processing time: 39.56 seconds

BigVGANをシンプルに動かす

初めに

音声からmel 情報を再構築をして再度音声に変換するライブラリ「BigVGAN」を動かしていきます。Demo用Gradioはありますが、あえて自分でコードを書いていきます

github.com

開発環境

準備

ライブラリの実行環境は ReadMeの通りに Anacondaを使用していきます

conda create -n bigvgan python=3.10 pytorch torchvision torchaudio pytorch-cuda=12.1 -c pytorch -c nvidia
conda activate bigvgan

音声情報を再構築

以下のコードは ReadMeのサンプルコードの続きで、再構築した音声情報から再度音声ファイルを保存しています

import torch
import bigvgan
import librosa
from meldataset import get_mel_spectrogram
import soundfile as sf

device = 'cuda' if torch.cuda.is_available() else 'cpu'

# instantiate the model. You can optionally set use_cuda_kernel=True for faster inference.
model = bigvgan.BigVGAN.from_pretrained('nvidia/bigvgan_v2_24khz_100band_256x', use_cuda_kernel=False)

# remove weight norm in the model and set to eval mode
model.remove_weight_norm()
model = model.eval().to(device)

# load wav file and compute mel spectrogram
wav_path = 'test.wav'
wav, sr = librosa.load(wav_path, sr=model.h.sampling_rate, mono=True) # wav is np.ndarray with shape [T_time] and values in [-1, 1]
wav = torch.FloatTensor(wav).unsqueeze(0) # wav is FloatTensor with shape [B(1), T_time]

# compute mel spectrogram from the ground truth audio
mel = get_mel_spectrogram(wav, model.h).to(device) # mel is FloatTensor with shape [B(1), C_mel, T_frame]

# generate waveform from mel
with torch.inference_mode():
    wav_gen = model(mel) # wav_gen is FloatTensor with shape [B(1), 1, T_time] and values in [-1, 1]
wav_gen_float = wav_gen.squeeze(0).cpu() # wav_gen is FloatTensor with shape [1, T_time]

# you can convert the generated waveform to 16 bit linear PCM
wav_gen_int16 = (wav_gen_float * 32767.0).numpy().astype('int16') # wav_gen is now np.ndarray with shape [1, T_time] and int16 dtype

# 生成された音声を保存 (float形式)
output_path = 'generated_audio.wav'
sf.write(output_path, wav_gen_float[0].numpy(), model.h.sampling_rate)
print(f"Generated audio saved to: {output_path}")

# 生成された音声を保存 (int16形式)
output_path_int16 = 'generated_audio_int16.wav'
sf.write(output_path_int16, wav_gen_int16[0], model.h.sampling_rate)
print(f"Generated audio (int16) saved to: {output_path_int16}")

UnityでRust版の形態素解析ライブラリ「Vibrato」を動かす

初めに

形態素解析で有名なものとして、Mecabがありますがより高速に動く Rust版のVibratoを以下の記事で動かしてみました。今回は、それをUnity上で動かしていきます。

ayousanz.hatenadiary.jp

今回の記事のUnityプロジェクトは、以下のリポジトリで公開しています

github.com

Rust側は以下になります

github.com

開発環境

  • Mac (M1)
  • Unity 2022.3.4f1

UnityでRustのライブラリを動かす方法について

RustのコードをUnityで動かすには,プラグイン化してUnity側からRustのコード(C言語)を呼ぶ必要があります。 今回は Apple silicon向けにビルドをしたRustのプラグインをUnityで動かしていきます

Vibratoをプラグイン

まずはプラグイン用にRustの形態素解析のコードを作っていきます

use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use vibrato::{Dictionary, Tokenizer};

#[no_mangle]
pub extern "C" fn tokenize(input: *const c_char, dict_path: *const c_char) -> *mut c_char {
    let input_str = unsafe { CStr::from_ptr(input).to_str().unwrap() };
    let dict_path_str = unsafe { CStr::from_ptr(dict_path).to_str().unwrap() };

    // 辞書ファイルのロード
    let reader = zstd::Decoder::new(std::fs::File::open(dict_path_str).unwrap()).unwrap();
    let dict = Dictionary::read(reader).unwrap();

    // トークナイザーの生成
    let tokenizer = Tokenizer::new(dict)
        .ignore_space(true).unwrap()
        .max_grouping_len(24);

    let mut worker = tokenizer.new_worker();
    worker.reset_sentence(input_str);
    worker.tokenize();

    let result: String = worker.token_iter()
        .filter(|t| {
            let words: Vec<&str> = t.feature().split(',').collect();
            let subwords: Vec<&str> = words[0].split('-').collect();
            subwords[0] == "名詞" || subwords[0] == "カスタム名詞"
        })
        .map(|t| format!("{}: {}", t.surface(), t.feature()))
        .collect::<Vec<String>>()
        .join("\n");

    CString::new(result).unwrap().into_raw()
}

#[no_mangle]
pub extern "C" fn free_string(s: *mut c_char) {
    unsafe {
        if s.is_null() { return }
        drop(CString::from_raw(s));
    };
}

またRust側の Corgo.toml は以下のように定義しています

[package]
name = "rust_tokenizer"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
vibrato = "0.5.0"
zstd = "0.12.3"

こちらを以下でビルドをします

cargo build --release --target aarch64-apple-darwin

これによって Apple silicon向けのビルドバイナリが以下のように作成されます.

Unity側でVibratoを呼び出す

Unity側では,ビルドをした 際に生成された librust_tokenizer.dylibプラグインフォルダの中に入れます。

また ipadic-mecab-2_7_0の 辞書ファイルを ipadic-mecab-2_7_0というフォルダ名を作ってその中に入れます。

次にUnity側からバイナリを呼ぶためのコードを作成します。

using System.Runtime.InteropServices;
using UnityEngine;
using System;
using System.IO;

public class RustTokenizer : MonoBehaviour
{
    [DllImport("__Internal")]
    private static extern IntPtr tokenize(string input, string dictPath);

    [DllImport("__Internal")]
    private static extern void free_string(IntPtr str);

    [DllImport("libdl.dylib")]
    private static extern IntPtr dlopen(string fileName, int flags);

    [DllImport("libdl.dylib")]
    private static extern IntPtr dlerror();

    void Start()
    {
        string libraryPath = Path.Combine(Application.dataPath, "Plugins", "librust_tokenizer.dylib");
        Debug.Log("Attempting to load library from: " + libraryPath);

        IntPtr lib = dlopen(libraryPath, 2); // RTLD_NOW = 2
        if (lib == IntPtr.Zero)
        {
            IntPtr errPtr = dlerror();
            string errorMessage = Marshal.PtrToStringAnsi(errPtr);
            Debug.LogError("Failed to load library. Error: " + errorMessage);
            return;
        }

        Debug.Log("Library loaded successfully");
        
        string input = "本とカレーの街神保町へようこそ。";
        string dictPath = Application.dataPath + "/Plugins/ipadic-mecab-2_7_0/system.dic.zst";

        IntPtr resultPtr = tokenize(input, dictPath);
        string result = Marshal.PtrToStringAnsi(resultPtr);
        free_string(resultPtr);

        Debug.Log(result);
    }
}

こちらを適当なオブジェクトにアタッチすれば,本とカレーの街神保町へようこそ。 に対する形態素解析が実行されます。

本: 名詞,一般,*,*,*,*,本,ホン,ホン
カレー: 名詞,固有名詞,地域,一般,*,*,カレー,カレー,カレー
街: 名詞,一般,*,*,*,*,街,マチ,マチ
神保: 名詞,固有名詞,地域,一般,*,*,神保,ジンボウ,ジンボー
町: 名詞,接尾,地域,*,*,*,町,マチ,マチ
UnityEngine.Debug:Log (object)
RustTokenizer:Start () (at Assets/Scripts/RustTokenizer.cs:43)

Macで形態素解析ライブラリ「Vibrato」を動かす

初めに

より速い形態素解析ライブラリを探していて,Mecab(および高速化)や jaggerよりも速いと言われている vibratoを触ってみます。

Demo

本とカレーの街神保町へようこそ。形態素解析した場合,以下のようになります 。

プロジェクトは以下で公開しています

GitHub - ayutaz/hello-Vibrato-rust: VibratoのRust版を動かすテスト

開発環境

Rust ver

$ rustc --version
cargo --version
rustc 1.80.0 (051478957 2024-07-21)
cargo 1.80.0 (376290515 2024-07-16)

MacにRustが入っていない場合は,以下の記事で導入する方法を記載しているため参考にしてください

ayousanz.hatenadiary.jp

Vibrato専用の辞書ファイルをダウンロード

まずはVibrato専用の辞書ファイルをダウンロードします。今回は一番サイズが小さいipadic-mecab-2_7_0を使用します。

ダウンロードしたら,以下で解凍をします。

tar xvf ipadic-mecab-2_7_0.tar.xz

Rustのプロジェクトの作成

次にRustのプロジェクトの作成をします

cargo new hello-rust

解凍した辞書もこのフォルダ内に移動します.

このプロジェクトの Cargo.toml は以下のように記載します。

[package]
name = "hello-rust"
version = "0.1.0"
edition = "2021"

[dependencies]
vibrato = "0.5.0"
zstd = "0.12.3"

Vibratoを動かす

Vibratoを動かしていきます。

main.rsを以下のように記載します。

use std::fs::File;
use std::env;
use std::io::{self, Read};
use vibrato::{Dictionary, Tokenizer};

pub fn mecab(dict_path: &str) {
    // 辞書ファイルのロード
    let reader = zstd::Decoder::new(File::open(dict_path).unwrap()).unwrap();
    let dict = Dictionary::read(reader).unwrap();

    // トークナイザーの生成
    let tokenizer = Tokenizer::new(dict)
        .ignore_space(true).unwrap()
        .max_grouping_len(24);

    // ワーカーの生成。mutableです。
    let mut worker = tokenizer.new_worker();

    // 標準入力から文章を読み込む
    let mut text = String::new();
    io::stdin().read_to_string(&mut text).unwrap();

    // 文章をセット。繰り返したい場合は、これを再度呼び出し、ワーカーを使い回す。
    worker.reset_sentence(&text);
    worker.tokenize(); // 形態素解析の実行。mutable self

    println!("num_tokens: {}", worker.num_tokens());

    // 抽出したトークンをループで表示する
    worker.token_iter()
        .filter(|t| { // 絞り込み
            let words: Vec<&str> = t.feature().split(',').collect();
            let subwords: Vec<&str> = words[0].split('-').collect();
            subwords[0] == "名詞" || subwords[0] == "カスタム名詞"
        })
        .for_each(|t| { // 出力
            println!("{}: {}", t.surface(), t.feature());
        });
}

fn main() {
    let args: Vec<String> = env::args().collect();
    
    if args.len() < 3 || args[1] != "-i" {
        eprintln!("Usage: {} -i <dictionary_path>", args[0]);
        std::process::exit(1);
    }

    let dict_path = &args[2];
    
    // mecab関数を呼び出す
    mecab(dict_path);
}

以下のコマンドを実行することで処理を実行することができます

echo '本とカレーの街神保町へようこそ。' | cargo run --release -p hello-rust -- -i ipadic-mecab-2_7_0/system.dic.zst

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

num_tokens: 10
本: 名詞,一般,*,*,*,*,本,ホン,ホン
カレー: 名詞,固有名詞,地域,一般,*,*,カレー,カレー,カレー
街: 名詞,一般,*,*,*,*,街,マチ,マチ
神保: 名詞,固有名詞,地域,一般,*,*,神保,ジンボウ,ジンボー
町: 名詞,接尾,地域,*,*,*,町,マチ,マチ