初めに
TTSをしている中で特定の音声同士を合わせた音声が欲しい時があります。TTSではマージがありますが、マージとは違うアプローチを考えていきます
VOICEVOXにはモーフィングが実装されているみたいなので、実装コードを眺めてみます。
🎉 モーフィング機能が実装されました!
— VOICEVOX (@voicevox_pj) 2023年1月31日
対応している音声を混ぜてモーフィングした音声を合成できるようになります。
2つの音声を一度生成してから、音声を機械的に分析・再合成する後処理を行うことで実現しています。 pic.twitter.com/nrcmOyyVV5
VOICEVOX Engineの以下がモーフィング部分ですが、pyworldを使用していることがわかります。
こちらを実際に触っていきます
Demo
以下が各内容の内容が入っているリポジトリになります
開発環境
実装のアプローチ
- クロスフェード
- DTWを使った実装
- 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