nw.js を使ってみる 5 - 簡易音楽プレーヤー
nw.js を使ってみるシリーズその 5。今回は簡素な音楽プレーヤーを作成してみた。
以下に実装中に気づいたことなどをまとめる。
音声再生
アプリのメイン機能となる音声再生は Web Audio API を利用。実装にあたり以下の記事が参考になった。
はじめ Web Audio API と Audio Element のどちらにするか迷ったのだが、音声データそのものをいじれてビジュアライザーやエフェクターも実装できそうなので前者を選んだ。
MP3 と MP4 のサポート
nw.js のサポートする音声や動画のフォーマットは同梱された FFmpeg モジュールによって決まる。そして nw.js に標準添付されているものは MP3 や MP4 をサポートしていない。FFmpeg モジュールで MP3 や MP4 を有効にすると GPL ライセンスの Copyleft が適用されるのだが nw.js は MIT ライセンスで配布するため意図的に外しているそうだ。
これらをサポートする場合は FFmpeg モジュールを置き換える必要がある。具体的な対応は nw.js の Wiki に解説されている。
私のサンプルも MIT として配布したいので標準のままとしている。ただし MP3 や MP4 が再生できないと音楽プレーヤーとしての実用性が損なわれるので、自分が使用するときは Wiki で紹介されているように Chrome 同梱のモジュールを手動で組み込んでいる。
と、ここまで書いていて午後のこ〜だのバイナリ配布問題を思い出した。
配布アプリには同梱せずエンド ユーザーが手動でモジュール組み込むことが許されるなら、そのための補助ツールを作成するのはどうだろう。例えば OS X なら /Applications
、Windows であれば Program Files
から Chrome を探して、その中の FFmpeg モジュールを自分のアプリ内にコピーしてしまうとか。
途中、そういうツールを作るか gulp タスクとして定義することも考えたけど、このアイディアを実装として具体化することに忌避感を覚えたのでやめておいた。
AudioContext.decodeAudioData のエラー
開発中、手持ちの音楽ファイルをいくつか試してみたのだが FFmpeg で MP4 がサポートされていても Apple Lossless の AAC は AudioContext.decodeAudioData
でエラーとなる。原因を調べようとしてエラー ハンドラを実装してみたのだが引数は null
であった。Stack Overflow にも javascript - decodeAudioData returning a null error というスレッドがある。
対応が面倒なのでエラー ハンドラが呼ばれたらそのままエラー扱いとしている。もしかするとフォーマットではなくファイルのサイズが巨大なことが原因なのかもしれない。エラーの発生するファイルは平均して数十 MB はある。
対応音声フォーマットのチェック
記事冒頭で参考プロジェクトに挙げた lolipop では Audio Element の canPlayType
を利用して対応する音声フォーマットをチェックしている。私のサンプルでもそのようにした。
しかしこのチェックを通過したものであっても前述の AudioContext.decodeAudioData
では Apple Lossless がエラーになる。canPlayType
と decodeAudioData
の対応フォーマットが一致するならチェック通過により音声形式としては妥当と推測できるので、やはりファイル サイズの問題なのだろうか。
今回は Web Audio API で実装したけど、あとで Audio Elrement も試してみたい。
音声ファイルのメタデータ
曲リストにタイトルやアーティストぐらいは表示したかったので musicmetadata というモジュールを利用。
代表的な音声フォーマットにはあらかた対応しており取得できるメタデータも実用十分。今回のサンプルでは使わなかったけど画像 (Buffer
とあるけど ArrayBuffer
のことだろうか?) も得られるのでビジュアルにこだわるなら役立つだろう。
説明を読みながらより多くのメタデータを操作できる npm を作ってみたくなった。npm の勉強にもなりそうなのでいずれ開発するかも。
pause と音声再生の終了
Web Audio API の AudioBufferSourceNode
は start/stop
のみで pause
をサポートしていない。そのため一時停止したいなら自前で対応する必要がある。
この実装は参考プロジェクトの buffaudio を踏襲。再生開始したらタイムスタンプを保持する。pause
時に現時刻からこれを差し引いた値と再生値を掛け合わせた値を記録し、play
時の AudioBufferSourceNode.start
へ指定することで位置を復元している。
ただし AudioBufferSourceNode.onended
をハンドリングしていると stop
実行時に呼ばれてしまうため、これを終了判定に利用する場合は注意が必要。pause
用の stop
なのに再生そのものが停止してしまう。
このあたりの挙動をフラグで管理していたのだが pause
と stop
の区別が面倒なのと、曲を連続再生するときの stop
から play
の遷移で play
後に onend
が発生して混乱するなどの煩雑さから却下。
最終的に再生の終了は外部で管理することにした。音声を再生している間は UI の再生時間やシークバーの位置を更新するため 1 秒間隔でタイマーを動かしている。よって、そのハンドラで再生位置が演奏時間を超えているか判定。ここで次曲への遷移もおこなう。
このあたり音声プレーヤーとなるオブジェクトのライフ サイクルを含めて再設計の余地がある。いわゆる RAII 的な実装としておき音声ソースとオブジェクトを 1:1 対応しておけば、ハンドラの非同期性も回避できそうな気がする (破棄の確実性を保証することが懸念か)。
Flux
この nw.js シリーズではアプリの UI 実装に React.js を利用しているのだが、今回は Flux にも手を出してみた。
途中までコンポーネント間の連携を props
のコールバック関数経由で実装していたのだが、音声プレーヤーの状態共有で詰まってしまった。今回のコンポーネントは以下のように構成されている。
MainViewModel
...UI 全体を包括するコンポーネントToolbarViewModel
...音声プレーヤー UI も含むツールバーMusicListViewModel
...曲リスト
はじめは ToolbarViewModel
に音声プレーヤーのインスタンスを state
として定義し、再生などの操作がおこなわれる度に MainViewModel
へ通知していた。しかしこの実装だと曲リストからダブルクリックされた曲を再生する場合は MainViewModel
を経由して ToolbarViewModel
へ操作を伝搬しなければならない。
ならば音声プレーヤーを MainViewModel
で持つべきか?というとそれは違う気がする。機能単位でコンポーネントを分けているなら音声プレーヤーが属すべきは ToolbarViewModel
のほうが自然だ。...という感じで逡巡しているうち Flux を使ってみたくなった。
音声プレーヤー部分は MVC や MVVM でいえば Model に位置づけられる (Renderer = View と解釈するなら Audio Renderer として V なのかもしれないけど)。ならばコンポーネントと切り離してして管理して操作リクエストと変更通知ハンドラだけ実装するほうがスッキリするのではないか。
というわけで Flux 入門。はじめてなのでまずは facebook/flux のサンプルを参考に実装。Dispatcher だけ flux を利用して、他は自前で実装。
Flux にすることで、コンポーネントの実装は非常に簡素化された。上位層となる MainViewModel
が Store のイベント ハンドラを実装し、その結果を state
に設定する。下層はそれを単に render
する。音声プレーヤーを操作したいならハンドラとか意識せず Action を呼ぶだけ。あとは勝手に Store へ流れてゆく。
理解の足りていない点は多々あるが、この程度の実装でも十分にありがたみを感じたので今後は Flux を使いたい。Fluxxor などの Flux 実装は...定番になりそうで学習コストが小さいなら採用を検討する。ざっと調べてみたけど今の私にはこれらの良し悪しを判断できるほどの見識がまだない。
開発環境
これから nw.js でアプリ開発してみたい人の参考になるかもしれないので、私の開発環境について書いておく。
環境 | 内容 |
---|---|
エディタ | Sublime Text 3 (Babel, ReactJS, SublimeCodeIntel, Stylus, JSHint, ...etc) |
ビルド | gulp (Browserify, watchify, reactify, node-webkit-builder, ...etc) |
nw.js | 0.12.0 |
OS | OS X Yosemite 10.10.2 |
ターミナル | iTerm2 |
JavaScript ライブラリ | React.js, Flux |
CSS ライブラリ | Stylus |
開発スタイルは以下のような感じ。
- iTerm を起動
- iTerm でタブを二つ開く
- タブ 1 はビルド用、開発中はファイル監視 & コンパイル (JS, CSS, SVG)
- タブ 2 は nw.js 用、事前に nw をエイリアスとして登録 (How to run apps を参照のこと)
- タブ 1 でコンパイルが走ったらアプリをリロード
README.md にリンクしてるスクリーンショットは Skitch で撮影したものを TinyPNG で圧縮した。GitHub でプロジェクトにアクセスしたときスクリーンショットがあると嬉しいので、最近はなるべく付けるようにしてる。
サンプル
今回のサンプルを以下に公開。
clone して README.md の Installation & Build に書かれた手順を実行するとアプリが生成される。