2009-11-04
■ [iPhone][Metronome] 「Metronome for Professional」をリリースしたので
![[iPhone][Metronome] 「Metronome for Professional」をリリースしたので - Ni chicha, ni limona - 平均から抜けられない僕 のブックマークコメント [iPhone][Metronome] 「Metronome for Professional」をリリースしたので - Ni chicha, ni limona - 平均から抜けられない僕 のブックマークコメント](http://r.hatena.ne.jp/images/popup.gif)
苦節ウンヶ月(恥ずかしくて言えない)、ようやく2作目のアプリをリリースすることができました。伝統的な振り子式のメトロノーム、「Metronome for Professional」です。
Metronome for Professional (AppStoreへのリンク)
このアプリのポイントは、
- 正確なテンポを刻む(誤差1ms以内)
- スムーズなアニメーション(そこらの無料アプリケーションには負けません!)
- ブックマーク機能を使えば(2+3)/5や3+2+3拍子といった変拍子も実現可能(サンプル付き)
- テンポ計測機能が、自分のミュージックライブラリを聴きながらでも可能
といったところです。
このアプリを作るために、プロの演奏家に何度もインタビューや試用してもらったり、メトロノームの最高峰:ウィットナー社のそれを楽器屋でビデオに撮って調べたり*1してきましたので、気に入っていただけると幸いです。
ちなみにハイテクっぽいUIのメトロノームにしなかったのは、演奏家が「振り子でないと拍がつかめない」「LEDが並んでいるモノもあるけれど、その途中のタイミングを振り子で見ているから、(そういう離散的なものや突飛なデザインは)だめ」という指摘を受けたためです。私自身は奏者として全然未熟なのですが、奥の深い世界ですよね。
要望などがあれば公式サイトのAbout画面にあるメールアドレスか、私のTwitterアカウントまでご連絡くださいませ。
--------------------------
【開発者向け解説記事】正確さの追求
さて、以降は本アプリの目玉でもある「正確さ」をどうやって実現したかを書きます。
危険な方法も使っていますが、Ready for Saleになったということで書いちゃいます。悪しからず御了承ください。
※けっこうベタベタな方法を使っていますので、もし「もっと良い方法があるよ」という方がいらしたらぜひぜひコメントしてください。大歓迎です。いやマジで。
A. タイマーを別スレッドで、しかも特殊スケジューリングで動作させる
本アプリでは正確な時間を刻ませるために、
- 「タイマーをスタート」
- 「指定時間が経過するとイベントが発火する」
- 「イベントの中で、次の発火までの早退時間を指定してスタート」
を繰り返し行うタイマー専用のスレッドを用意して、時間の管理をそれに任せる方針を採りました。シングルスレッドではそもそも正確なタイマー管理は無理なのと、フレームワーク任せにして未知のオーバーヘッドが発生するのを可能な限り避けるためです。
したがって標準提供されているNSThreadやNSOperationQueueを使用せず、以下のテクニックを使用しました。
- スレッドにはpthreadを使用(POSIXのスレッド操作APIを叩くため。NSThreadでも大丈夫かもしれませんが、不整合が起きたらいやだと思ってフレームワークの処理から外しました)
- スレッドのスケジューリングをSCHED_FIFOに指定
- スレッドの優先度を1以上に指定
- スレッドでタイマー待ちをしているとき、いつでもキャンセルして処理を続行可能
以下が上記を用いたスレッドを作成するときのソースです。中〜下段辺りの処理が注目点です(次節を読むときに上段部の処理をご参照ください)。
// コールバック関数と共にタイマースレッドを生成(まだ開始はしない) int KSTimerCreate(callbackFunc func) { // Mutexや条件変数の初期化 pthread_mutex_init(&threadLoopLock, NULL); // タイマーをサスペンドさせるときのためのmutex pthread_mutex_lock(&threadLoopLock); // Lock状態からスタート(誰かがunlockすることでタイマースタート) pthread_mutex_init(&intervalLock, NULL); // タイマー満了を待つためのmutex pthread_cond_init(&intervalCond, NULL); // タイマー待ちの途中でキャンセルしたいときのための状態変数 // 以下は全て成功するとみなしてエラー判定していません(本来はすべきですが) // - プライオリティを設定 // - 親のスケジューリング(SCHED_OTHER)を継承しない // - FIFOスケジューリングに設定 // - デタッチはしない(リスタートのため) pthread_attr_init(&threadAttr); struct sched_param priority; priority.sched_priority = 50; // 普通のスレッドは0なので1以上であれば幾つでもいいですが、とりあえず pthread_attr_setinheritsched(&threadAttr, PTHREAD_EXPLICIT_SCHED); // これを忘れるとFIFOにならない pthread_attr_setschedpolicy(&threadAttr, SCHED_FIFO); // これでFIFOスケジューリングに変更 pthread_attr_setschedparam(&threadAttr, &priority); // 以上の変更をコミット // スレッド作成 function = func; // タイマーが満了したときのコールバック関数(引数として渡ってくる) state = kKSTimerSuspended; pthread_create(&thread, &threadAttr, (void *)runLoop, NULL); // runLoop関数をスレッドの先頭処理(スタックの底)として処理開始 return 0; }
これで、runLoopが別スレッドで、しかも他のスレッドよりも高優先度かつFIFOスケジューリングで動くようになりました。
たとえばメインスレッドが何か処理を実行していたとしても、実行状態になったら強制的にこのスレッドに処理が渡ります。
ちなみにNSThreadと違ってスレッドのデタッチは行っていません。別に精度に違いはないのでどちらでも良いのですが、今回は終了処理が確実に行われたかどうかをチェックするためjoinを待つようにしただけです。
注意:
FIFOスケジューリングの処理なんかでwhile(1){}なんか(もしくは同等の無限ビジーループ)をすると痛い目を見ます(ホームボタンも効かず、Xcodeのリモートデバッガでしかプロセスを殺せない=iPhone単体でアプリを起動すると電源すら落とせない)。くれぐれも取り扱いには注意してください。
B.タイマースレッドの本処理(キャンセル機能付)
次はタイマーを実際に実行するときの処理です。ポイントは以下のとおりです。
- タイマー全体をスタートさせるためにmutexを使用
- タイマー待ちにnanosleep()を用いず、pthread_cond_timedwait()を使用(nanosleep()を使えば精度の高い待ちを実現できるが、途中のキャンセルがシグナルでないと行えない←iPhoneではシグナル不可。本APIは同じ精度を持ちながらも、同時に使用するmutexで待ちをキャンセルできる)
タイマー関係のAPIは、Cocoa touchフレームワークにも色々と用意されているわけですが、Objective-Cのオーバーヘッドを嫌ったことと、中で何をしているか分からない(=精度を把握できない)ということで却下しました。
以下、タイマー待ち部分(スレッドのメインループ)のソースです。
static void runLoop(void *arg) { while (1) { // サスペンドからの復帰待ち(別スレッドからの、本mutexのunlock待ち) pthread_mutex_lock(&threadLoopLock); // メインループ(この中ではObjective-Cのコードに触れていないので、AutoReleaseプールは不使用←オーバーヘッドの除去) // グローバルなstate変数を直接扱っているのは本来は危険ですが、FIFOスケジューリングなのと面倒くさいのとで無視 while (state == kKSTimerRunning) { struct timespec wakeInterval = {0}; // 関数ポインタをコール&次の時間まで待ち if (state == kKSTimerRunning) { function(&wakeInterval); // コールバック関数を呼ぶと同時に、次のタイムアウトまでの時間を計算してもらう } // 待ち(キャンセル付き)処理 pthread_mutex_lock(&intervalLock); // まずはロック // 次のWakeUp時間(絶対時間)を計算 timespecadd(&nextWakeTime, &wakeInterval, &nextWakeTime); int retcode = pthread_cond_timedwait(&intervalCond, &intervalLock, &nextWakeTime); // 返戻値がタイムアウトならループ継続、さもなければタイマーをサスペンド(threadLoopLockのtake待ちに戻る) if (retcode != ETIMEDOUT) { pthread_mutex_unlock(&intervalLock); break; } pthread_mutex_unlock(&intervalLock); } if (state == kKSTimerTerminated) break; // もしstate変数が変更されていたらタイマーの終了。グローバル変数の言い訳は(ry } // スレッド終了。片付けることがあれば何かする }
以上です。pthread_cond_timedwait()から処理が戻ってくるときの値によって、タイムアウトかキャンセルかを分けているわけです。
キャンセルするときの関数(抜粋)は以下のとおりです。
int KSTimerPause() { state = kKSTimerSuspended; pthread_mutex_lock(&intervalLock); // 下の処理を行うための準備 pthread_cond_signal(&intervalCond); // 状態変数による待ちを叩き起こす pthread_mutex_unlock(&intervalLock); // 起こしたらアンロック return 0; }
ここは見てのとおりですね。pthread_mutex_lock ()&pthread_cond_signal()によって待ち状態を叩き起こします。
C.「次の満了時間」を正確に測る
さて、いくら高優先度のFIFOスケジューリングでも「処理」→「待ち」→「処理」→…を繰り返していれば、「処理」ぶんのオーバーヘッドが積み重なってしまい、やがて遅延が出始めます。
この遅延をいかに抑えるか、という点が本節のポイントです。
この問題をどのように解決するかを考え始めるとなかなか難しく、たとえば組込の世界ではハードウェア割り込みによるタイマー(ウォッチドッグタイマ、と呼ばれることが多いです)を使って、プログラムとは別のクロックで処理を行わせることで精度を保っていたりします。
しかし、iPhone SDKにはハードウェアクロックを知るAPIなどはありません(低レベルのAPIになるので、そりゃそうですが)。
私もここで悩んだわけですが、でもよく考えてみればpthread_cond_timedwait()で指定するのはepoch timeからの相対時間==絶対時間だったので、この関数への引数で「次に起きる時刻」さえ正確に指定し続けていれば問題ないことに気がつきました。
なのでここでは特殊なことはしていません。上のソースで出てきたtimespecadd()も、単に前回発火した時間に、次の発火予定時間を足しているだけです。
POSIX万歳、です。
--------------------------
以上です。これらによって、高精度なアプリを実現できました。
・・・うーん、こうして書いてみるとあまりトリッキーなことや、高度なことはやっていないですね。
書いててちょっと恥ずかしくなってきてしまいましたが、まあ、いいです。
ご参考程度にどうぞ。
けれど、それよりもなによりも実際にアプリを使って頂いた方が気持ちよく使えるかどうかが一番ですので、今後はもっと精進していきたいと思います。
さっそく幾つかアプリの要望も頂いていますので、楽しみにしていてくださいませ。
今後ともKatokichiSoftを宜しくお願いします。
*1:だって高いから買えません・・・。