2015年の一人反省会

今年もやったこととかやらなかったこととか色々あったと思うけど、手を出した中でも中途半端な状態で投げ出していて自分でも勿体ないなあと感じているのは、夏の少し前ぐらいに Sinsy を触って遊んでいた奴。

触って遊んでいたと言っても Web インターフェイスから音声を生成していたのわけではなくて、プログラムから叩いて遊んでみていた。


触り始めた発端は単純で、CeVIO のピアノロールでガイド表示を有効にしている時に 1/32 だと描画内容からは想像ができないレベルで重いので、バグレポートついでに「そんなに重くなるのか?」というのを自分で実装して試してみようということで始めた。
ただ C# はまともに触ったことがないし、取りあえず C++ で JUCE を使って作業を始めた。
JUCE も触ったことはなかったものの、API を眺めたことぐらいはあったので精神的な障壁が低めだった。

CeVIO で表示している相当の情報を Sinsy から貰ってきて描画するだけだから専門的な知識がなくても必要な関数をちょちょいと呼べばいけるだろうと高をくくっていたのだが、これが思いの外手間がかかった。

外部リソースを埋め込めるようにする

スクリーンショットでもわかるように、最初から Android もテスト環境として試していた。
クロスプラットフォームで動くプログラムを作る場合外部リソースの読み込みは結構環境ごとに差があってあまり触れたくない領域で、実際 JUCE でもソースコードに埋め込む手段が標準で用意されているのでそれを使いたかったのだが、ソースコードを少し読んでみると Sinsy::setLanguages とか Sinsy::loadVoices などのインターフェイスが引数にファイル名などを受け取る形しかなくリソースをソースコードに埋め込むことができなかった。

だからまず最初に std::istream からリソースを読み込めるようにオーバーロードを追加して、Sinsy を使う上で外部ファイルに全く依存せずに実行可能な環境を整えることから始めた。

「C ならともかく C++ なら抽象化されたストリームが使えるのに何故そんな構造に」とか「辞書として言語ごとに shift_jis/euc_jp/utf_8 とか文字コードを複数用意するつもりっぽいけど、言語と文字コードは本来別の問題だから『UTF-8 でよこせ』っていう仕様にした方が後々良くない?」とかいくつか疑問があったけど、多分色々事情もあるだろうしそれ自体もまた別の問題だから取りあえず見なかったことにした。
(特にストリームの話に関しては内部で使われている C の hts_engine API にまで話が及んでいてもう少し面倒臭い話になっている)

ともあれ変更によって全てのリソースの埋め込めるようになった。

なんかデバッグビルドが重くて辛い

試しに Wave ファイルを書き出して出力のテストをしてみていたのだが、デバッグビルドでは Wave 書き出し完了までやたら遅いし、リリースビルドでも CeVIO と比べると何となく遅いような感じがした。
なぜだろうかと思って少し調べてみたら、どうやら hts_engine API の HTS_alloc_matrix など複数の箇所でループ内で HTS_calloc を大量に呼ぶコードがあり、こういう箇所がかなり足を引っ張っているようだった。
デバッグビルドでは格段に重くなるのもリーク検出のためかと思うと合点がいく。

HTS_calloc の実装を見てみると #ifdef FESTIVAL でライブラリがあるなら多分これの safe_wcalloc を呼んで、ないなら素直に malloc を呼ぶというコードになっていた。そしてその #ifdef が終わった後には確保した領域を memset で 0 埋めしている。
このコードだと safe_wcalloc が呼ばれた場合には無駄に memset してしまうし、なぜライブラリがない場合に calloc じゃなくて malloc なんだろうかと、またいくつか疑問はあった。

でも取りあえず1番大きな問題は HTS_calloc の大量呼び出しで、必要なければ頻繁に行うべきではないのは自明なので必要な量のメモリをサイズ計算して一度で全部確保するように変更した。
こうすると当然開放側でも HTS_free を一度呼べば全て開放できるし。

何箇所かを似たようなアプローチで書き換えてデバッグビルドが耐えられる程度の速度になった。

表示周りを作るためには拍子から

今度は楽譜データを管理する内部構造と、それに伴う GUI 周りを JUCE と共に作り始めた。

最初に手を付けるべきなのは拍子を扱う機構だろうと思い、いわゆるコンダクタートラックを考え始めた。
というのも、当初の目的ではガイドの線がどの程度の負荷で描画できるのか、というのを試してみることだったので、まずは途中で拍子が変わることに対応できなければいけないと思ったから。

キーと拍子をまとめて扱うクラス(位置は小節単位での絶対位置として管理)を作って、std::deque に突っ込むだけで即席なコンダクタートラックは完成した。

厳密に楽譜を再現する場合にはキーを扱うのに位置情報が小節単位の精度では上手くいかないはずではあるのだが(小節の途中でキーが変わる楽譜もあるから)、キー情報自体ピアノロールに対してはかなり些末な情報と思えるので気にしないことにした。

ピアノロールの左側にある鍵盤表示

この時点でノート情報が存在しないピアノロールをレンダリングするには最低限必要なデータが揃ったので、JUCE で GUI を考え始めた。とはいえ特別オリジナリティのあるインターフェイスにはせず CeVIO に似たところを攻めるだけでいいので、ViewportSlider を組み合わせたコンポーネントでピアノロールのメイン領域を準備しつつ、まずは左側にある鍵盤描画に着手し始めた。

ところで、ピアノロールの鍵盤描画は思っていたより様々なアプローチがあるようでとても面白い。
ここではピアノロールが回転していることを踏まえ鍵盤の縦の高さのことを「幅」と呼ぶことにする。
  • FLStudioSONAR のインターフェイスでは白鍵の幅がバラバラになっている。
    常に黒鍵の中央に白鍵の分かれ目があるものとして扱う場合、こういう鍵盤が生まれる。
  • VOCALOID やこっちの FLStudio では白鍵も黒鍵も完全に同じ幅として描画している。
    鍵盤らしさはないが、ピアノロールのノート表示との相性を考えるとこれも一つの答えだと思う。
  • DominoSinger Song Writer(ABILITY)、そして CeVIO では恐らく正確な幅で描画している。
    Studio One も 3 からはこの形式になったようだ(以前は白鍵バラバラ方式)。
  • Cubase は個性的な方式を採用している。
    鍵盤描画は正確な描画なのだが、ノートが置かれるエリアでは白鍵が連続するEF、BCの間に余白を挿入することで、白鍵だけを並べた時に等間隔になるようにしている。なおメリットは不明。
上記一覧で正確な描画としてまとめた中でも恐らく実際には更に方式は細分化されていて、Domino は本当に正確で、他はちょっとだけズルをしているんじゃないかと思う。
(実際には拡大率とか描画時の座標丸め込みとかの関係で判断難しいのかもしれないけど)

というのも、白鍵を1オクターブの中で配置すると7個で、黒鍵は12等分にしたエリアの 2, 4, 7, 9, 11 番目、ノート配置エリアも同じく12等分したエリアに配置されることになる。
すると、1オクターブを12等分したノート配置エリアと鍵盤の白鍵の線の位置が一致するのがBC間の一箇所だけになり、EF間の線はかなり惜しい位置にあるものの一致しない(1.0/12.0*5.0 = 0.41666666666666663, 1.0/7.0*3.0 = 0.42857142857142855)。

文章だとややこしいけど、要するにこういうことだ
正確に描画した結果ずれているように見えるのも面白い話ではあるのだが、この合いそうで合わないモヤモヤ感を解消するためには、1オクターブ内で白鍵の幅をCDE と EFGAB で別に扱う必要がある。

まず1オクターブの幅を12で割り、それをノート配置エリアと黒鍵の幅とする。
そして CDE の幅を 黒鍵の幅*5/3、EFGAB の幅を 黒鍵の幅*7/4 とすれば揃うようになる。
この幅の差は凄く小さいので見た目上ほとんど正確に見える。

そういう感じでピアノロールの左側にある目安程度の鍵盤の描画にはちょっとしたドラマがあると感じつつ、鍵盤の描画処理を完成させて次の段階へ進むのだった。

ノート配置エリアの描画に関しては特に面白い話はなくて、拍子の変更情報を読みつつ線を引くだけだった。

ノートの配置

まずトラックデータを格納する機構から。
基本的なアプローチはコンダクタートラックと似た感じになるが、位置情報を PPQN 単位での絶対位置で保持して(4分音符を例えば 480 などにする)、それを基底クラスとし、継承してノートイベントを作る。
また、コンダクタートラックにまだテンポ情報がないので、同じクラスを継承してテンポイベントも作る。
これらもやっぱり std::deque に突っ込むだけでそれぞれのトラックとしては基本的には完成。

ところで位置情報に関しては MIDI ファイルの構造を知っているとデルタタイムを参考に相対位置で管理したくなる部分でもあるのだが、絶対位置から相対位置を求める場合は自分のひとつ前との差を見れば済むのに対し、相対位置から絶対位置を求めるには自分より前の全てのイベントを足さないと結果が出せないので、効率を考えると絶対位置で保持するほうがいいだろうと思い、この構造にした。
絶対位置を符号付き 32bit 整数に格納しても PPQN が 480 の時 2147483647/1920 ≒ 1118481 小節までは進めるし、実際に使う上で困ることはほとんどない。

描画に関しては表示だけなら特に難しいことはなくて、画面外のものを描画しないとか基本的なところを抑えておけばパフォーマンスも問題ないようだった。

Sinsy からタイミング情報などを取得

ノート情報を Sinsy に叩き込んで CeVIO の TMG パネルで表示するようなデータを貰おうと思ったら、どうやらその辺のデータを外側に持ち出す機構がなさそうだったので、手を入れる必要があった。
同じようにやはりピッチやボリュームデータを取り出す仕組みも見当たらないし、取り出す機能がないという事は当然書き換えたデータを代わりに渡す機構もないし、必要になる機能が色々足りない。
元々ビブラート関係の仕組みはオープンソース版の Sinsy には多分入っていないんだろうとは思うのでその辺は良いとしても、このままではデータの入出力のストリーミングも出来ないし、Sinsy 側での内部処理を理解しなくてもいけるだろうというのは幻想でしかなかったのが浮き彫りになりつつあり、この頃から気分的に盛り下がってくる。

あと元のライブラリとの差分が増えてくるとバージョンアップを取り込む時のマージ作業も面倒くさくなっていくのであまりやりたくない作業でもあった。
とはいえ、Sinsy や hts_engine API は変更前にローカルの git リポジトリに最初にコミットして、そこから変更分をコミットするようにしているので全部手作業でやるよりはかなりマシなはずではある。

少し変更することでタイミング情報は取れたので、ピアノロール上へのオーバーレイ表示にも取り組み始める。
小節単位で表示しているピアノロールに実時間ベースで来るタイミングデータをオーバーレイ表示するのは地味に面倒な部分ではあるものの、テンポ情報を見ながら愚直に計算する以外の方法が見つからないのでそのように作った。

タイミング情報が描画できればピッチ情報も近からず遠からずみたいな感じなので同じように描画処理を作った。JUCE の Graphics::strokePath を使うとアンチエイリアシングもされて、線も滑らかに。

作業に一旦の区切り

ここまでの時点でノートの追加、範囲選択、移動、削除などはできるものの歌詞入力は未実装(日本語入力周りの関係で手間が掛かりそうだったから)、ピッチの書き換えや反映を未実装、ボリュームの取得や書き換えや反映を未実装、オーディオデータの再生も未実装。
この辺りでだいぶ興味が薄れてきてノート編集周りの挙動を気持ちよくなるようにしたりとかしながら、段々ソースコードを触らなくなっていった。

ここからは未着手なので推測だけども、ピアノロールで位置を指定して再生するためには波形の任意の位置からの再生ができなければいけない。
例えば10分ぐらいのシーケンスがあって、最初の5秒が欲しい場合は頭のほうだけを処理したいし、シーケンスの中央辺りを編集した時は頭の方を処理し直す必要がなさそうに思えるし、こういう部分で不要な処理を削り取りたい。
今回のプログラムはノートを変更した時など再生対象になるシーケンスが確定したタイミングで裏で別スレッドで生成を始めておいてシームレスに更新する仕組みにはしているけど、ラグ自体は解消しないからタイミング次第では改善しないし、VST とかでの連携を視野に入れると再生開始時に少し音が出ないとか微妙な挙動にもなりそうだし、この辺をもう少し上手く動くようにしたかった。

そのため Sinsy で任意の位置からのストリーミング再生をする方法を探っていたのだが、HtsEngine::synthesize の中で使われている HTS_Engine_get_generated_speech はその名の通り既に生成済みの波形データをコピーしてくるだけなので、これではシーケンス全体のサイズに関わらず一定に近い処理量で任意位置から再生することはできそうになかった。

どうやら肝になるのはその前に使われる HTS_Engine_synthesize_from_strings が呼ぶ HTS_Label_load_from_strings と HTS_Engine_synthesize で、負荷のバランスを見た限りでは HTS_Label_load_from_strings は軽くはないけど HTS_Engine_synthesize に比べれば所要時間は短い。
なので重要そうなのは HTS_Engine_synthesize で行われる3パスの波形生成処理で、ここを細かい単位で複数回に分けて実行できるように、更に可能であれば途中からの実行、無理でも前回からの変更差分のみを最低限の範囲で再処理できるようにしたかった。

そう思って少しずつソースコードを読んでみたりはしたものの、基本的に必要となる前提知識が足りないというか無いので各処理がどんな役割があり変わるとどうなるのかがわからず、たいへん手を出しにくい(実際出していない)。

そういうわけで今回は自分にとっては大きな課題を残したまま放置することになった。
基本的な部分の知識を持っていないが故に瑣末な問題が気になるんだろうとも思った。

そんな感じで色々な部分が大量に残っているおもちゃ以下のプログラムなのだが、それでも当初の目的だった「ガイド表示の線が増えると重いのか」というのに関してはやっぱりそんなに重くならないということで自分の中では落ち着きつつある。
ただ少なくとも JUCE のレンダラでは線を引く時に素直に引くとアンチエイリアシングの影響で地味に重くて、代わりに fillRect で細長い長方形を描くと速度が出ることがわかった。
CeVIO は点線で描画してるけどそれで重くなるのだろうか。その辺は調べていないので謎。

ちゃんと記録取ってなかったので正確なところはわからないものの、ここまでで作業開始から多分1~2ヶ月程度経っている。ライブラリの仕様がわからない状態から始めた割には良く出来たほうかな、とは思っている部分も少しあるが、MARI-ON を作った時は1ヶ月で完成まで辿り着いているので良く出来てないような気もする。色々な面で違っててあんまり比較にはならないけど。

JUCE は標準の状態では日本語が豆腐になるし Android 対応はこの時点では実験的な状態でもちろん日本語は入力できないし、問題は少なくなかったので実は結構その辺にもじわじわと時間を取られた。
しかも OpenGL ベースのレンダラを使わないと Android では描画速度も結構厳しくて、使うとそれはそれで他の問題も起こるし、楽譜に対する入力効率も含めやはりPCとスマホやタブレットで同じプログラムの流用は使い物にならなそうなのが実情かなぁというぼんやりとした印象だった。

あと Sinsy のオープンソース版は他のプログラムから呼んで使うためのものというよりはやっぱり技術デモという感じなのかな、というのがちょっと触ってみた感じでの印象だった。

少し前に JUCE は 4.x 系も出たし、年末に Sinsy と hts_engine API もバージョンアップしたみたいなので、また気が向いたら弄くり回してみようと思う。

2015年の一人反省会は大体こんな感じ。