今回は、DA変換器とSDカードI/Fを使って、音声合成に挑戦です🔥🔥🔥。
最近の人工知能(AI)が喋る、流ちょうな音声合成とは異なり、「いかにもコンピュータに喋らせました」的な、黎明期の音声合成ですので、あんまりハードルを上げない様、お願い申し上げます。
「音声合成」と聞いて、真っ先に思いつく方法で、実現します。
日本語には、表意文字である「漢字」の他に、表音文字である「ひらがな」が存在します。音声合成して、音として発声させるのですから、表音文字に対して処理するのが順当です。そこで、「ひらがな」に着目すると、日本語のひらがなは「五十音」と言って、綺麗な直交テーブルになっています。
つまり、「あかさたな……(子音)」と、「あいうえお(母音)」で直交しているので、発声テーブルを作り易いはずです。
子音(あかさたな……)×母音(あいうえお)の組み合わせで、1文字づつ発声した音声データをWAVファイルとして蓄積しておき、各々、読み上げたい文字に対応するWAVファイルを再生していくことで、任意の言葉を喋らせることができるはずです。
例えば、「あいうえお」を発声する時には、「あ」の発声データをWAVファイルから再生 → 続いて「い」のデータを再生 → ……と、言う手順です。
WAVファイルの再生は、「#7-9 WAVファイルを再生する」で実験しましたので、その応用で行けます。
ただし、各文字の発声データは、それぞれ1つしか持たない形式となるので、言葉の抑揚やイントネイション、アクセントなどを表現することができません。そこら辺が、「いかにも」な感じの、「黎明期の音声合成」たる所以です。
早速、発声用のWAVファイルを生成して行きたいと思います。
前節で、「五十音」とは書きましたが、実は、50個の音だけでは、全く足りません。
まずは、五十音が必要です。ローマ字を併記すると、「あaかkさsたtなnはhまmやyらrわw」×「あaいiうuえeおo」のマトリクスで50音となります。
ここにまず、濁点と半濁点の付いた子音を追加する必要があります。
ローマ字を併記すると、「がgざzだdばbぱp」×「あaいiうuえeおo」の25音となります。
さらに、伸ばす音「ー」である長音と、詰まる音「っ」である促音が、全ての文字に対して必要となります。
さらにさらに、カタカナ語に対応するために、「母音(あいうえお)」とではなく、「ゃぃゅぇょ」の小さい文字とのマトリクスが必要となります。ただし、この場合、組み合わせる相手は子音ではなくて、各行「い段」の文字とのマトリクスとなります。
どういうことか?と言うと、
子音と母音とのマトリクスでは:「かきくけこ」……これは既に上で議論済み
か行い段「き」と「ゃぃゅぇょ」とのマトリクスでは:「きゃkyaきぃkyiきゅkyuきぇkyeきょkyo」
「キャンバス」に使われる「きゃ」や、放射能の単位「キュリー」に使われる「きゅ」等が含まれます。「カタカナ語」とは言ってみましたが、「きょう(今日)」の「きょ」なども含まれてますね。
例示した「か行い段」の「き」だけでなく、全ての「い段子音」に適用する必要があります。
ただし、わ行に関しては、い段の「い」ではなくて、「う」とのマトリクスで、「うゃwyaうぃwyiうゅwyuうぇwyeうょwyo」のデータを発生させることにします。ほとんど使い道がなさそうですが、「ウェイト」の「うぇwye」、「ウィーク」の「うぃwyi」などが含まれています。
「や行い段」の「いゃyyaいぃyyiいゅyyuいぇyyeいょyyo」には、考えうる範囲で使えそうな文字は1つもないのですが、マトリクスの直交性を維持して処理を簡略化するため、一応、発声データには含めておきます。
まとめて、ローマ字を併記すると、「きkしsちtにnひhみmいy(や行い段)りrうw(わ行う段)ぎgじzぢdびbぴp」×「ゃyaぃyiゅyuぇyeょyo」の70音となります。
最後に、忘れずに「んnn」もカウントすると:(15(akstnhmyrwgzdbp)+14(kstnhmyrwgzdbp&y))×5(aiueo)×3(そのまま・長音・促音)+1(nn)=436音となります。
これでもまだ対応できない文字にお気づきでしょうか?「ティーカップ」の「てぃ」がありません。
「て」は、た行ですが、い段ではなくて、え段なので、「ち(た行い段)」×「ゃぃゅぇょ」には含まれませんでした。一方、「ちぃtyi」と言う音は、長音を使い、「ちーti-」としても代用可能です。そこで、ここだけ例外として、「ちゃtyaてぃtyiちゅtyuちぇtyeちょtyo」の発声データを生成することとします。
同じ理由で、「ぢぃdyi」も、「でぃdyi」と発声させることで、「アルディーノ」等に対応可能とします。
これで大抵の発声には対応できるはずです。
五十音と言いながら、50音では全く足りず、436音もの発声データを、WAVファイル化する必要があるとわかり、途方に暮れてしまいました。
436音、自分でマイクに向かって発声して、WAVファイルに録音しますか??
止めましょう。以前より何度か使っていますが、Windows 10には、音声合成API(SAPI;Speech API)がインストールされていて、拙作のsPeachを使うと、テキストファイルを読み上げさせることができます。読ませるだけではなくて、WAVファイルに出力することも可能なので、この機能をうまく使えないか?と考えました。
でも、sPeachだと、1文字づつWAVファイルに出力するのも、全てのWAVファイルをミキシングするのも、手作業となってしまい、その手間を考えると、まだ現実的ではありません。
そこで、拙作Olive+のスクリプトを使うことにしました。
Olive+のスクリプト言語でも、SAPIを使ってテキストの読み上げを実行したり、読み上げデータをWAVファイルに保存することができます。さらに、sPeachの機能である、WAVファイルのミキシングにも対応しているので、発声データ(WAVファイル)の生成を自動化できます。
スクリプトの手順としては、「あいうえお かきくけこ ……」と言ったマトリクスに従って、1文字づつ発声させたWAVファイルを436個生成します。以下の様な感じです。
aviwav speak, "あ", $(tmpfn)#(cnt)".wav";
cnt=cnt + 1;
aviwav speak, "い", $(tmpfn)#(cnt)".wav";
cnt=cnt + 1;
: 436文字分繰り返し
※実際のスクリプトでは、各文字の生成にもマクロを使っているので、もう少し複雑な記述になっています。完成版スクリプト(speak_jp.olv)でご確認ください。
最後に、436個のWAVファイルをミキシング機能で1本のWAVファイルに統合して完成です。
aviwav mixing, "speak_jp.wav", "mixing.csv";
※同
436本のWAVファイルのままでは、再生する度にファイルのopen/closeが必要となり、Arduinoの様に非力なMCUで実行する場合の足かせになりますので、最後にミキシングして、1本の発声データファイル「speak_jp.wav」に統合しておくことが、肝要です。
Olive+のスクリプトをWindows 10 Home (x64)で実行させて、この時の1音分のWAVファイルをsPeachで開いて確認したところ、以下の様になっていました。
436音分の発声データを1つのWAVファイルに詰め込んでおき、後で任意の文字だけを抜き出してWAV再生したいので、上のデータを根拠に、「1音のデータを500ms間隔で整然と並べる」ことに決めました。従って、例えば、表の中で145番の文字(speak_jp.olvでは、145:「あー」)を発声させたい場合には、145×500ms=72.5s〜73.0sの間を再生すれば良いことになります。
※なお、この検証結果は、執筆時点のWindows 10 Home+SAPIによる結果です。アップグレイドなどによって、SAPIの仕様が変わったり、別バージョンのWindows(8等)で実行すると、異なる結果となる場合があります。適宜修正してお使いください。
完成したスクリプトを以下にリンクしておきます。
完成スクリプト:speak_jp.olv
このスクリプトをOlive+で読み込んで実行してください。436音分のWAVファイル生成と、ミキシング(統合)を実施するので、そこそこ時間は掛かります。ただし、436音、自分でマイクに向かって録音することを思えば、スクリプトの実行終了を待つだけなので、苦にはならないはずです。
終了すると、スクリプトと同じディレクトリに「speak_jp.wav」と言うファイルができたはずです。このWAVファイルは、16ビットデータ、ステレオ(2チャンネル)になっています。Arduinoで再生するには、8ビットデータ、モノラル(1チャンネル)にしないと、処理能力が足りなくて、雑音にしか聞こえなくなってしまうので、sPeachなどを使って所望のフォーマットに変換してください。sPeachを使った変換方法はこちらで解説してあります。
変換したら、Arduinoで使用するSDカードにコピーしておいてください。ファイル名は、デフォルトでは「speak_jp.wav」を認識しますので、特に問題なければこの名前で、SDカードのルートディレクトリにコピーしてください。
※完成したWAVファイルは、結構な大きさになるので、提供いたしません。この先の実験を進めるのであれば、各自、スクリプト(speak_jp.olv)を実行して「speak_jp.wav」を生成してください。スクリプト(.olv)の実行にはOlive+が必要です。ダウンロードコーナやベクターのHPからダウンロードしてインストールしてお使いください。
Arduino IDEだと、日本語はunicode(UTF-8)になるのですが、個人的に好みではありません(断然、S-JIS派)。そこで、Arduinoに読み上げさせる文字列は、7ビットASCIIコードで表現する「ローマ字」とします。
ここまでに示してきた、訓令式ローマ字を基本として、ヘボン式ローマ字には対応しません。
例:「ちゃ」は、「cha」ではなく「tya」
例:「じ」は、「ji」ではなく「zi」
例:「しゃ」は、「sha」ではなく「sya」
さらに、長音(定まったルールがない?)と、促音(次の子音を重ねる)の処理は、解析が複雑化するので避け、長音にはハイフン(−)、促音には一重引用符(’)を使うことにします(ここから変則)。
「ん」も、本来のルールでは、「n」の次が母音なら「な行」、母音以外なら「ん」となるのですが、これも処理が複雑化するので、常に「nn」と表記することに決めます。
例:「真っ白」 → 「ma'siro」
例:「トータル」 → 「to-taru」
例:「満天の星」 → 「manntennnohosi」
例:「きゃりーぱみゅぱみゅ」 → 「kyari-pamyupamyu」
例:「11月」 → 「zyuuitigatu」または「zyu-itigatu」
例:「仕事のABC」 → 「sigotonoeibi-si-」
Arduinoで読み上げ可能な文字列は、この変則ローマ字のみです。アルファベットや数字などは一切発声できないので、上例の様に、変則ローマ字に変換して発声させてください。
処理速度の限られたArduinoに於いて、解析しながら発声するので、処理の複雑化は極力避けたいと考えました。
うまくすると、いつものArduino UNOでもなんとか処理できるかもしれませんが、まずは、Cortex-M3 @72MHzと、処理速度に余裕のあるSTM32ボード(下図)をターゲットにしたいと思います。
「#7-10 WAVファイルを再生する(回収編)」で実験して解った様に、処理速度が出力レートを決め、出力レートが音質に直結するので、まずは、STM32でトライします。余裕があれば、Arduino UNOでも試してみたいと思います。
STM32は3.3V I/OのMCUですので、SDカードI/Fも、12ビットDA変換器(MCP4725)も全て3.3Vで動作させれば、レベルシフタなしで回路を構築することができます(下図が必要な部材)。
※手作りのSDカードI/Fについては、「#6-5 microSDカードI/Fを自作する」をご覧ください。市販のSDカードI/Fでも、構成可能です。12ビットDA変換器(MCP4725)の使い方に関しては、逆引き索引から記事を漁ってみてください。
「#7-9 WAVファイルを再生する」にて、ノイズ対策のために追加したアクティブLPFも効果が無かったので、取り外して、回路図は以下の様にシンプルなものになりました。
※この回路をSTM32で構築すると、「#7-10 WAVファイルを再生する(回収編)」で作成したプログラム(play_wav.ino)を、STM32ターゲットでコンパイル&書き込んで、SDカード上のWAVファイルを再生することもできます。
処理の流れは、変則ローマ字を解析 → WAVデータ読み出し → DA変換器へ出力、して喋らせます。
変則ローマ字で記述された文字列データを、先頭文字から順に解析して行きます。以下、解析手順です。
ここまでくれば、発声データ(WAVファイル)内の再生位置は、nchr×<1音分の時間spk_usec_vlen=500ms>+<1音の開始時間オフセットspk_usec_vofs=100ms>で、求められます。
変則ローマ字解析位置にて、句読点(「。」および「、」)に対する、無音時間を確保するために、空白( )、カンマ(,)、ピリオド(.)の3文字を認識します。挿入する無音時間の長さは、空白( ):カンマ(,):ピリオド(.)=1:2:3とし、一番短い空白( )の時間を、プログラム先頭付近の定数spk_msec_rest(デフォルト=100ms)で指定可能です。
無音時間に関する文字を複数重ねた場合には、重ねた分だけ時間待ちが発生します。例えば、「,.,.」で、約1sの無音時間となります。
また、認識できないローマ字を見つけた場合には、無視して読み飛ばします。エラーなどの表示は無いので、もし、発声されなかったり、意図しない文字を発声するようなら、まずは、変則ローマ字に誤りが無いかどうか、確認してみてください。
※デバッグ用に、#define DEBUG_ROMANを用意しておきましたので、必要に応じてご活用ください。
前節のnchrを使って、WAVファイル内の再生位置(時間)がわかっているので、「#7-10 WAVファイルを再生する(回収編)」で示したプログラム(play_wav.ino)で作った、fget_wave12()関数がそのまま流用可能です。
こちらも、12ビットDA変換器(MCP4725)へ出力するだけなら、dac12_write()関数を流用して終了です。
しかし、ここでは、もう少し考えてみる必要があります。
ただでさえ、抑揚のない、不自然な単音の羅列となる、今回の音声合成において、全ての音を同じ時間間隔で喋らせるのは、更に不自然さを助長する結果となります。
先程示した図の再掲となりますが、以下図は500ms間隔で発声データに統合した発声データのサンプルです。他の文字のデータも色々と眺めてみると、読み上げた文字によって発声時間が短かったり、長かったり、まちまちです。
これを、常に500ms毎に再生するのではなく、無音部分を検出して、そこで再生を打ち切り、次の文字に移った方が、文字と文字の間が空かずに、少しはマシになるのではないか?と、期待できます。
まず、発声データは、SAPIで喋らせてWAVファイルに落としてあるので、無音部分が「ほぼ」無音です。8ビット符号無データ換算では、128と言うデータになっています。12ビット符号無データ換算では2,048です。
プログラム内の処理は、12ビット符号無データで統一してあるので、2,048±αの範囲を無音と識別します。以下、無音部分検出コードの抜粋です。
// silence detection
if (abs (wdat - 2048)>spk_sildet_lsb)
stat=1;
else if (stat>0 && abs (wdat - 2048)<=spk_sildet_lsb)
stat++;
if (stat>=spk_sildet_cnt)
break;
変数statが、再生中の状態を表しています。
アルゴリズムとしては、こんな感じでしょう。あとは、実際に喋らせてみて、定数を調整します。
注意点は、spk_sildet_cntによる、無音部分の検出手段が、「回数」であって、「時間」ではないことです。このコードは、DA変換器へアナログデータを出力するループ内の処理なので、出力レート毎に実行されます。よって、出力レートが変わってしまうと、spk_sildet_cntの最適値も変わってしまう(再調整が必要)ということです。
MCUの処理速度もそうですが、発声データ(WAV)ファイルのフォーマットが変わっただけでも、出力レートは変化するので、再調整が必要となります(WAVファイルのフォーマットと出力レートの変化については、こちらを参照してください)。
では、完成版プログラムをリンクしておきます。
完成版プログラム:speak_jp.ino
STM32で回路を構成した後、コンパイル&書き込んでおいてください。シリアルモニタから、以下のコマンドを受け付けるようになっています。
以下、実験中の様子です。
実験サンプル:【 】内はシリアルモニタからの送信データ
【spk,konnnitiwa,watasinonamaewa,arudyi-no desu】
OK+ speak konnnitiwa,watasinonamaewa,arudyi-no desu
OK: speak: 3731768[us]/38284[smpl]=97[us/smpl](10259[Hz])
喋るのに要したWAVファイルの再生時間と、その間にDA変換器へデータを出力した回数、データ出力レート、その逆数である出力周波数が情報として表示されます。
アンプ内蔵スピーカに出力して、スマホのボイスレコーダソフトウェアで録音したデータをお聞かせします。
:ar86_rec_stm.wav
こんな感じですけど、どうですか?何とか、聞き取れませんか?
実験サンプル:【 】内はシリアルモニタからの送信データ
【spk,nihonngo sukosi,hanasemasu】
【spk,zyuuitigatu rokuniti,gogo sitizi sannhunn】
【spk,tihayaburu,kamiyomokikazu,tatutagawa.】
【spk,karakurenayini,mizukukurutowa】
【spk,naninitukaukawa,anatano aideasidai desu】
後は、皆様の耳でご確認ください。
予告通り、Arduino UNOでも試してみました。結論から言うと、UNOでもちゃんと聞き取ることができました。
回路は、「#7-10 WAVファイルを再生する(回収編)」で示した回路図がそのまま使えます。プログラムも、前節でSTM32用にコンパイル&書き込んだspeak_jp.inoが、そのまま使えるようにしてあります。
以下、実験中の様子です。
※尚、以前の実験で効果が認められなかったアクティブLPFは削除、MCP4725への電源は5Vを供給するなど、簡略化した回路で実験しました。
実験サンプル:【 】内はシリアルモニタからの送信データ
【spk,konnnitiwa,watasinonamaewa,arudyi-no desu】
OK+ speak konnnitiwa,watasinonamaewa,arudyi-no desu
OK: speak: 3228636[us]/14064[smpl]=230[us/smpl](4356[Hz])
ご覧の通り、出力レートがSTM32の倍以上に伸びているにもかかわらず、STM32に比べても聞き劣りしませんでした。な〜んだ、UNOでも全然行けるじゃない。信じてあげられなくてゴメン → UNOっち🤟。
以下、UNOで喋らせた時の録音データです。
:ar86_rec_uno.wav
こそっと、ノイズについての考察を一切挟まなかったのですが……、「#7-10 WAVファイルを再生する(回収編)」で指摘したノイズ(恐らくデジタルノイズの回り込み)については、今回の回路でも対策はしていません。あしからず。