SPRESENSEでボイスチェンジャーのサンプルが次のGitHubに公開されています。音声をFFT変換し、その周波数スペクトルをシフトして声色を変えるというものです。
しかし、これだけではイマイチ機械音ぽくてもっとうまく声色を変える方法はないだろうかと調べてみました。
男声と女声の違い
男声と女声の違いについて調べてみるとフォルマントという言葉がよく出てきます。フォルマントとはFFTスペクトルの包絡線の頂点のことをいいます。
引用:「恋音」による「恋声」のピッチ・フォルマント変換の観察2
この図の最も高い頂点を第一フォルマント、次の高い頂点を第二フォルマントと呼びます。男声と女声はこの第一フォルマントと第二フォルマントで大きく特徴が異なります。
引用:自分の女声の長所、弱点を知ろう!「母音のフォルマント」を調べる
この表を見ると、男声を女声にするには周波数にオフセットを加えて、また周波数の幅が高くなるほど広がるように処理をすればよさそうです。そのためには、スペクトルの包絡線を求める必要があります。
ケプストラム解析
ケプストラム解析とは、FFTスペクトルの包絡線を求めて信号の性質を解析する手法のことです。特に音声認識の領域でよく使われています。さきのフォルマントを検出するのにも使われています。
音声とは声帯で発生するパルスが、声道を通って伝わってくるモデルと考えられます。この時、音声は声帯の振動と声道フィルタの掛け算で表現できます。
この掛け算の式は、周波数空間に投影しスペクトルのログを取ると足し算に変わります。足し算に変われば、あとはフィルターによって分離することができます。ということは、このLogスペクトルを信号として見て、FFTをかければ声帯振動と声道フィルターを分離できるということです。声帯振動だけ取り出せれば、声道の影響を排除できるため、性差に関係する部分は抑制できるはずです。
\begin{align}
&x(t)=g(t)h(t) &時間空間\\
\\
&X(f)=G(f)H(f) &周波数空間\\
\\
|X(f)|&=|G(f)||H(f)| &スペクトル\\
\\
log|X(f)|&= log|G(f)H(f)| \\
&= log|G(f)| + log|H(f)|
\\
\end{align}
FFTのスペクトルのログをFFTをかけた結果は時間空間に逆変換されるのですが、これをケプストラムといいます。低次のケプストラムはスペクトラム包絡に対応しています。したがって、低次のケプストラム波形にFFTした結果はスペクトル包絡線になります。
FFTした結果をFFTをかけて、フィルタをしてさらにFFTをするという多少いかれた信号解析方法なのですが、このスペクトルの包絡線は設備診断や故障解析などにもよく使われているので覚えておいて損はないでしょう。特にパイプとファンの関係も似たような構造になります。パイプの共鳴振動とファンの振動を分離するのにも使えます。
スペクトル包絡を使って声色を変えてみる
声の性質はスペクトル包絡に現れることがわかりましたので、スペクトル包絡を使って声色を変えることができるはずです。最初のフォルマントの特徴を見ると、得られたスペクトル包絡をシフトして、フォルマントの間隔を広げてあげればよさそうです。
SPRESENSEに実装をしてみる
このフォルマントを使った声色変換をSPRESENSEに実装してみたいと思います。プログラムは次のものを流用させてもらいました。
この signal_process 関数に処理を実装していきます。リアルタイムで処理をしたいのでなるべく計算量は少なくしたいのですが、必要な処理だけでもかなり多いです。位相もただしく計算したいところですが、とても間に合うとは思えないので虚数部をそのまま流用する形にしました。
FFT処理の回数が実に4回。内挿処理もかなり重い処理で、さらにローパスフィルタの処理も行っています。とても処理ができる量には思えませんが、実装してみた結果がこちらです。繰り返しになりますが、このコード中の signal_process 関数に上記処理が記述されています。
#include <FrontEnd.h>
#include <OutputMixer.h>
#include <MemoryUtil.h>
#include <arch/board/board.h>
#include <float.h>
#define SAMPLE_SIZE (1024)
//*****
#define ARM_MATH_CM4
#define __FPU_PRESENT 1U
#include <cmsis/arm_math.h>
#define TAPS 16
arm_fir_instance_f32 firS;
arm_rfft_fast_instance_f32 fftS;
static float pCoeffs[TAPS];
static float pState[TAPS+SAMPLE_SIZE-1];
const uint32_t LPF_CUTTOFF_FREQ_HZ = 4000;
//*****
FrontEnd *theFrontEnd;
OutputMixer *theMixer;
const int32_t channel_num = AS_CHANNEL_MONO;
const int32_t bit_length = AS_BITLENGTH_16;
const int32_t sample_size = SAMPLE_SIZE;
const int32_t frame_size = sample_size * (bit_length / 8) * channel_num;
bool isErr = false;
void frontend_attention_cb(const ErrorAttentionParam *param) {
Serial.println("ERROR: Attention! Something happened at FrontEnd");
if (param->error_code >= AS_ATTENTION_CODE_WARNING) isErr = true;
}
void mixer_attention_cb(const ErrorAttentionParam *param){
Serial.println("ERROR: Attention! Something happened at Mixer");
if (param->error_code >= AS_ATTENTION_CODE_WARNING) isErr = true;
}
static bool frontend_done_cb(AsMicFrontendEvent ev, uint32_t result, uint32_t detail){
UNUSED(ev); UNUSED(result); UNUSED(detail);
return true;
}
static void outputmixer_done_cb(MsgQueId requester_dtq, MsgType reply_of, AsOutputMixDoneParam* done_param) {
UNUSED(requester_dtq); UNUSED(reply_of); UNUSED(done_param);
return;
}
static void outputmixer0_send_cb(int32_t identifier, bool is_end) {
UNUSED(identifier); UNUSED(is_end);
return;
}
static void frontend_pcm_cb(AsPcmDataParam pcm) {
static uint8_t mono_input[frame_size];
static uint8_t stereo_output[frame_size*2];
static const bool time_measurement = false;
if (time_measurement) {
static uint32_t last_time = 0;
uint32_t current_time = micros();
uint32_t duration = current_time - last_time;
last_time = current_time;
Serial.println("duration = " + String(duration));
}
frontend_signal_input(pcm, mono_input, frame_size);
signal_process((int16_t*)mono_input, (int16_t*)stereo_output, sample_size);
mixer_stereo_output(stereo_output, frame_size);
return;
}
void frontend_signal_input(AsPcmDataParam pcm, uint8_t* input, uint32_t frame_size) {
/* clean up the input buffer */
memset(input, 0, frame_size);
if (!pcm.is_valid) {
Serial.println("WARNING: Invalid data! @frontend_signal_input");
return;
}
if (pcm.size > frame_size) {
Serial.print("WARNING: Captured size is too big! -");
Serial.print(String(pcm.size));
Serial.println("- @frontend_signal_input");
pcm.size = frame_size;
}
/* copy the signal to signal_input buffer */
if (pcm.size != 0) {
memcpy(input, pcm.mh.getPa(), pcm.size);
} else {
Serial.println("WARNING: Captured size is zero! @frontend_signal_input");
}
}
void signal_process(int16_t* mono_input, int16_t* stereo_output, uint32_t sample_size) {
int k;
uint32_t start_time = micros();
//***/
const static int pitch_shift = 5;
static float pTmp[SAMPLE_SIZE];
static float pSpc[SAMPLE_SIZE];
static float pLog[SAMPLE_SIZE];
static float pCep[SAMPLE_SIZE];
static float pEnv[SAMPLE_SIZE];
static float pOut[SAMPLE_SIZE];
static float pExtEnv[SAMPLE_SIZE/2];
memset(&pTmp[0], 0, SAMPLE_SIZE*sizeof(float));
memset(&pSpc[0], 0, SAMPLE_SIZE*sizeof(float));
memset(&pLog[0], 0, SAMPLE_SIZE*sizeof(float));
memset(&pCep[0], 0, SAMPLE_SIZE*sizeof(float));
memset(&pEnv[0], 0, SAMPLE_SIZE*sizeof(float));
memset(&pOut[0], 0, SAMPLE_SIZE*sizeof(float));
memset(&pExtEnv[0], 0, (SAMPLE_SIZE/2)*sizeof(float));
q15_t* q15_mono = (q15_t*)mono_input;
arm_q15_to_float(&q15_mono[0], &pTmp[0], SAMPLE_SIZE);
arm_rfft_fast_f32(&fftS, &pTmp[0], &pSpc[0], 0);
// calc magnitude
arm_cmplx_mag_f32(&pSpc[2], &pTmp[1], SAMPLE_SIZE/2-1);
pTmp[0] = pSpc[0];
pTmp[SAMPLE_SIZE/2] = pSpc[1];
// calc log. reuse imaginary number
memcpy(&pLog[0], &pSpc[0], SAMPLE_SIZE*sizeof(float));
k = 0;
for (int n = 0; n < SAMPLE_SIZE; n+=2) {
pLog[n] = log(abs(pTmp[k++]));
}
// calc Cepstrum
arm_rfft_fast_f32(&fftS, &pLog[0], &pCep[0], 1);
static int lifter = 30;
memset(&pCep[lifter], 0, (SAMPLE_SIZE-lifter)*sizeof(float));
// calc Spectrum Log Envelope
arm_rfft_fast_f32(&fftS, &pCep[0], &pEnv[0], 0);
// interpolation process of the log-spectrum envelope
const float a = 2.0;
int k1, k2;
k = 0;
while(true) {
k1 = int(k*a);
k2 = int((k+1)*a);
if (k2 >= SAMPLE_SIZE/2) break;
float alpha = (pEnv[k*2+2]-pEnv[k*2])/(k2-k1);
for (int i = k1; i < k2; ++i) {
pExtEnv[i] = alpha*(i-k1) + pEnv[k*2];
}
++k;
}
// create the pitch shift spectrum using the log-spectrum envelope
k = 0;
for (int n = pitch_shift*2; n < SAMPLE_SIZE; n+=2) {
pOut[n] = exp(pExtEnv[k]); // reverse log to real
pOut[n+1] = pSpc[k*2+1]; // copy imerginary
++k;
}
arm_rfft_fast_f32(&fftS, &pOut[0], &pTmp[0], 1); // ifft, bitReverse
arm_fir_f32(&firS, &pTmp[0], &pOut[0], SAMPLE_SIZE); // low pass filter
arm_float_to_q15(&pOut[0], &q15_mono[0], SAMPLE_SIZE);
mono_input = (int16_t*)q15_mono;
//***/
/* clean up the output buffer */
memset(stereo_output, 0, sizeof(int16_t)*sample_size*2);
/* copy the signal to output buffer */
for (int n = SAMPLE_SIZE-1; n >= 0; --n) {
stereo_output[n*2] = stereo_output[n*2+1] = mono_input[n];
}
uint32_t duration = micros() - start_time;
//Serial.println("process time = " + String(duration));
return;
}
void mixer_stereo_output(uint8_t* stereo_output, uint32_t frame_size) {
/* Alloc MemHandle */
AsPcmDataParam pcm_param;
if (pcm_param.mh.allocSeg(S0_REND_PCM_BUF_POOL, frame_size) != ERR_OK) {
Serial.println("ERROR: Cannot allocate memory @mixer_stereo_output");
isErr = false;
return;
}
/* Set PCM parameters */
pcm_param.is_end = false;
pcm_param.identifier = OutputMixer0;
pcm_param.callback = 0;
pcm_param.bit_length = bit_length;
pcm_param.size = frame_size*2;
pcm_param.sample = frame_size;
pcm_param.is_valid = true;
memcpy(pcm_param.mh.getPa(), stereo_output, pcm_param.size);
int err = theMixer->sendData(OutputMixer0, outputmixer0_send_cb, pcm_param);
if (err != OUTPUTMIXER_ECODE_OK) {
Serial.println("ERROR: sendData -" + String(err) + "- @mixer_stereo_output");
isErr = true;
}
}
void initializeFirLPF(const uint32_t CUTTOFF_FREQ_HZ) {
float Fc = (float)CUTTOFF_FREQ_HZ/AS_SAMPLINGRATE_48000;
const int H_TAPS = TAPS/2;
int n = 0;
for (int k = H_TAPS; k >= -H_TAPS; --k) {
if (k == 0) pCoeffs[n] = 2.*Fc;
else {
pCoeffs[n] = 2.*Fc*arm_sin_f32(2.*PI*Fc*k)/(2.*PI*Fc*k);
}
++n;
}
for (int m = 0; m < TAPS; ++m) {
pCoeffs[m] = (0.5 - 0.5*arm_cos_f32(2*PI*m/TAPS))*pCoeffs[m];
}
arm_fir_init_f32(&firS, TAPS, &pCoeffs[0], &pState[0], SAMPLE_SIZE);
}
//***/
void setup() {
Serial.begin(115200);
//***
initializeFirLPF(LPF_CUTTOFF_FREQ_HZ);
arm_rfft_fast_init_f32(&fftS, SAMPLE_SIZE);
//***
/* Initialize memory pools and message libs */
initMemoryPools();
createStaticPools(MEM_LAYOUT_RECORDINGPLAYER);
/* setup FrontEnd and Mixer */
theFrontEnd = FrontEnd::getInstance();
theMixer = OutputMixer::getInstance();
/* set clock mode */
theFrontEnd->setCapturingClkMode(FRONTEND_CAPCLK_NORMAL);
/* begin FrontEnd and OuputMixer */
theFrontEnd->begin(frontend_attention_cb);
theMixer->begin();
Serial.println("Setup: FrontEnd and OutputMixer began");
/* activate FrontEnd and Mixer */
theFrontEnd->setMicGain(0);
theFrontEnd->activate(frontend_done_cb);
theMixer->create(mixer_attention_cb);
theMixer->activate(OutputMixer0, outputmixer_done_cb);
delay(100); /* waiting for Mic startup */
Serial.println("Setup: FrontEnd and OutputMixer activated");
/* Initialize FrontEnd */
AsDataDest dst;
dst.cb = frontend_pcm_cb;
theFrontEnd->init(channel_num, bit_length, sample_size, AsDataPathCallback, dst);
Serial.println("Setup: FrontEnd initialized");
/* Set rendering volume */
theMixer->setVolume(-60, -60, -60);
/* Unmute */
board_external_amp_mute_control(false);
theFrontEnd->start();
Serial.println("Setup: FrontEnd started");
}
void loop() {
if (isErr == true) {
board_external_amp_mute_control(true);
theFrontEnd->stop();
theFrontEnd->deactivate();
theMixer->deactivate(OutputMixer0);
theFrontEnd->end();
theMixer->end();
Serial.println("Capturing Process Terminated");
while(1) {};
}
}
なんと、ここまでの処理をSPRESENSEで出来てしまいました。実験結果を貼り付けておきます。ただ、処理がかなり負担が大きいせいか、数分で落ちてしまいます。ここは改善点ですね。
処理前の声(自声)
処理後の声
処理音声は得られたものの、もうちょっと、それっぽい声になってほしかったところです。思ったほど女声に変えることはできずに、少し機械的なオネエっぽい声になってしまいました。それでも今回はリアルタイムでケプストラム解析ができることがわかったのは大きな収穫でした。
ケプストラム解析は、音声認識や設備診断、故障解析にも多く使われます。SPRESENSEによるエッジコンピューティングの幅を広げることができそうです。
作ってみた感想
位相の計算はさすがに処理が間に合いませんでしたが、ここまでの処理が21.3ミリ秒の間に出来るとは思いませんでした。虚数部を使いまわしているせいか、なかなか自声の雰囲気がとれなくて苦労しました。しばらくして落ちてしまう原因は間違いなく内挿処理なので、この処理を効率化できればもっと安定して動くようになると思います。ARM CMSIS ライブラリに Interpolation 関数があるので、これを使いこなせれば高速化ができるかも知れません。時間に余裕があったら、AIで音声処理することにもチャレンジしたいと思っています。音声処理は時間制約があるので、そこが大変でもあり面白いところでもありますね。