Electron を試す 4 - 簡易音楽プレーヤー 2
前回、音楽プレーヤーを作成したのだが、もうすこし機能を盛り込みたくなった。せっかく Spectrum を取得しているのだから Analyzer 表示がほしい。ジャケット画像も表示できたら更によし。というわけで実装と Electron 的な考察などをまとめてみる。
Spectrum Analyzer
前回というより元となった nw.js を使ってみる 5 - 簡易音楽プレーヤーのサンプル時点で音声 Spectrum 取得は実装されていたが UI を決めかねていた。専用のスペースを作るのか?それとも Electron なので専用ウィンドウを個別に表示するのか?などなど。
結局は画面上部の中央にある再生情報を Spectrum Analyzer と共用することにした。この部分をクリックすれば再生情報と Spectrum Analyzer がトグル式で切り替わる。
本機能の実装は以下の記事を参考にした。
Spectrum の取得
サンプルでは音楽の再生に Web Audio API を利用している。音声のデコードは Audio で処理。それに対して GainNode と AnalyserNod を接続。これらのうち後者から再生している音声の Spectrum を得られる。以下はその実装。
export default class AudioPlayer {
constructor() {
this._context = ( () => {
const audioContext = ( window.AudioContext || window.webkitAudioContext );
if( audioContext ) { return new audioContext(); }
throw new Error( 'Web Audio API is not supported.' );
} )();
this._audio = null;
this._sourceNode = null;
this._gainNode = this._context.createGain();
this._gainNode.gain.value = 1.0;
this._gainNode.connect( this._context.destination );
this._analyserNode = this._context.createAnalyser();
this._analyserNode.fftSize = 64;
this._analyserNode.connect( this._gainNode );
this._isPlaying = false;
}
get spectrums() {
if( !( this._sourceNode && this._isPlaying ) ) { return null; }
const spectrums = new Uint8Array( this._analyserNode.frequencyBinCount );
this._analyserNode.getByteFrequencyData( spectrums );
return spectrums;
}
}
事前に AnalyserNode.fftSize を設定しておけば、その 1/2 の要素数を持つ値コレクションを取得できる。この例では fftSize
を 64
としているため 32
の要素を持つ。コレクションは Uint8Array
なので値の範囲は 0
〜 255
。spectrums
プロパティを参照したら最新の値コレクションを算出して返す。
Canvas による描画
サンプルの View 部分は React で管理している。そのため Spectrum 描画を React コンポーネントと Canvas のどちらにするか迷った。
React コンポーネントにした場合、spectrums
を props
へ指定する設計になるだろう。しかしこの値の算出は参照された時点に限定されてしまう。そのため初回だけ更新、あとは上位コンポーネントからの更新を待つことになる。
なるべくリアルタイムに描画するためには window.requestAnimationFrame のコールバックで forceUpdate
するような実装が必要。...それなら Canvas でよくないか?ということでそうした。とはいえ Canvas むき出しなのも扱いにくいため、これを包含する React コンポーネントを実装。
const MAX_SPECTRUM = 255;
export default class SpectrumAnalyzer extends React.Component {
constructor( props ) {
super( props );
this._canvas = null;
this._canvasContext = null;
this._animationId = null;
this._playbackStateBefore = props.audioPlayerStore.playbackState;
}
componentDidMount() {
this._canvas = this.refs.canvas;
this._canvasContext = this._canvas.getContext( '2d' );
this._canvasContext.scale( window.devicePixelRatio, window.devicePixelRatio );
}
componentWillUnmount() {
this._stopSpectrumAnayzer();
}
render() {
// Update spectrum analyzer
const playbackStateNow = this.props.audioPlayerStore.playbackState;
if( this._playbackStateBefore !== playbackStateNow ) {
if( playbackStateNow === PlaybackState.Stopped ) {
this._stopSpectrumAnalyzer();
} else {
this._startSpectrumAnalyzer();
}
this._playbackStateBefore = playbackStateNow;
}
const style = { display: this.props.useSpectrumAnalyzer ? 'block' : 'none' };
return (
<div
className="audio-player__container__info__container__spectrum-analyzer"
style={ style }
onClick={ this.props.onClickInfoDisplay }>
<canvas ref="canvas">
</canvas>
</div>
);
}
_drawBackground( spectrums, baseX, width ) {
this._canvasContext.fillStyle = '#ecf0f1';
this._canvasContext.fillRect( 0, 0, width, this._canvas.height );
for( let i = 1, max = spectrums.length; i < max; ++i ) {
this._canvasContext.fillRect( i * baseX, 0, width, this._canvas.height );
}
}
_drawGraph( spectrums, baseX, width ) {
let percent = spectrums[ 0 ] / MAX_SPECTRUM;
let height = this._canvas.height * percent;
let y = this._canvas.height - height;
this._canvasContext.fillStyle = '#bdc3c7';
this._canvasContext.fillRect( 0, y, width, height );
for( let i = 1, max = spectrums.length; i < max; ++i ) {
percent = spectrums[ i ] / MAX_SPECTRUM;
height = this._canvas.height * percent;
y = this._canvas.height - height;
this._canvasContext.fillRect( i * baseX, y, width, height );
}
}
_startSpectrumAnalyzer() {
const spectrums = this.props.audioPlayerStore.spectrums;
if( !( spectrums ) ) { return; }
this._adjustCanvasSize();
this._canvasContext.clearRect( 0, 0, this._canvas.width, this._canvas.height );
const max = spectrums.length;
const width = ( this._canvas.width / max ) / 2;
const baseX = width * 2;
this._drawBackground( spectrums, baseX, width );
this._drawGraph( spectrums, baseX, width );
this._animationId = requestAnimationFrame( this._startSpectrumAnalyzer.bind( this ) );
}
_stopSpectrumAnalyzer() {
this._canvasContext.clearRect( 0, 0, this._canvas.width, this._canvas.height );
cancelAnimationFrame( this._animationId );
}
_adjustCanvasSize() {
const width = this._canvas.offsetWidth * window.devicePixelRatio;
const height = this._canvas.offsetHeight * window.devicePixelRatio;
if( this._canvas.width !== width ) { this._canvas.width = width; }
if( this._canvas.height !== height ) { this._canvas.height = height; }
}
}
再生状態の判定なども含めているため Flux Store 部分と疎にしきれていないのだけど大意は把握できると思う。renderer
メソッド以降が描画部分。
Canvas のサイズ変更へ対応する必要がある。Retina などの高解像度ディスプレイも想定しなければならない。このあたりの管理は以下の記事を参考に実装した。
Canvas の領域サイズは CSS 側で親にあわせて width/height
ともに 100%
とする。こうしておけば DOM のリサイズ機能を利用可能。これらは offsetWidth/offsetHeight
へ反映される。これを前提として描画のタイミングで Canvas の描画サイズとなる width/height
に offsetWidth/offsetHeight
を同期。無用なリサイズを避けるため処理は値の更新を判定して実行するようにした。
高解像度への対応は Window.devicePixelRatio を利用。この値はディスプレイの描画ピクセル比率を格納している。例えば 2015/10 時点における Mac の Retina ディスプレイなら 2
が設定されるはず。この値を CanvasRenderingContext2D.scale() の x
と y
に指定することで描画の座標系がディスプレイと一致。更にこの値を Canvas の width/heigth
にも掛ければ描画領域も適切なサイズになる。
Spectrum 部分は背景とグラフの 2 種類を描画。ただし交互に色などを変えながら描画するならワンセットよりも分割したほうが効率的なのでそうした。この知識は以下の記事から得たもの。
この工夫は 32
(Spectrums) x 2
(背景とグラフ) ぐらいだと誤差の範疇だけど、効率的であることを知ってしまうと反映したくなる。エンジニアの性というか、そんな感じ。早すぎる最適化ともいう。
ジャケット画像の取得と表示
音楽情報の取得で採用している musicmetadata は音楽ファイルに埋め込まれたジャケット画像を取得できる。いままでこのデータは捨てていたのだけど今回はこれを活用してみる。
画像の取得と保存
音楽ファイルに画像が埋め込まれているなら musicmetadata から取得した Object
の picture
に Buffer
として格納される。
現在の musicmetadata 処理は Main プロセスで実行しているため、Node 由来の Buffer
をそのまま Renderer に渡すのは抵抗がある。もし Buffer
としてそのまま処理できるにしろ、それを IndexedDB に保存したり View 部分の img.src
に指定するのは厄介そう。
特に後者は Base64 変換が面倒。しかも同じアルバムに属する曲だと画像も一緒なことが多いだろうから冗長性を回避するため一意性の管理を含めた画像キャッシュ機能もほしくなる。そのうえ img.src
に指定するときは data:image/jpeg
や png
などの MIME 指定まで必要だ。やっていれらない。
というわけで Buffer
を画像ファイルとして出力。そのパスだけを管理する方針で実装する。
画像ファイルの保存先は userData
直下の images
ディレクトリとした。userData
はアプリ固有の場所で IndexedDB や Web Storage もここへ保存される。画像ファイルとして保存する場合でも冗長性の問題はついてまわる。これは画像の一意性を担保・判定することで対応。処理は以下のようになる。
- 画像となる
Buffer
の SHA-1 Hash を得る - Hash をファイル名とする
images
ディレクトリ内に同名ファイルが存在しない場合だけ保存- 保存された画像ファイルのフルパスを音楽情報に設定 (IndexedDB や表示に使用)
この方法だと SHA-1 Hash が衝突しない限り、画像の一意性を保証できる。ジャケット画像として指定されるぐらいのデータ サイズ (大きくても 32bit * w256 * h256 ぐらい?) なら衝突を心配する必要もないだろう。
ファイル名の拡張子については musicmetadata の返す picture.format
に基づき決定。ここには jpg
や png
が設定される。これは id3v2.3.0 の Attached_picture を踏襲しているのだろうか。
表示
画像の表示は非常に簡単である。img.src
へ画像ファイルのフルパスを指定するだけでよい。Windows はダメかも?と思っていたが OS X と Windows のどちらもフルパス指定で画像を表示できた。サンプルの表示箇所は再生情報と次項で解説する Artist & Album View になる。
同一パスのファイルなら Chromium 側で同じ画像として扱われてキャッシュも含めて効率的に処理してくれるだろう。画像ビューアーでもないわけだし、こうした機能は Chromium 任せで十分と考える。
Artist & Album View
iTunes のアーティスト表示を参考にアーティストとアルバムを表示する UI を実装してみた。全アーティストの一覧が左、その選択に対応するアルバムと所属する曲の一覧を右に表示する。
曲情報の階層化
従来の実装では全曲をひとつのテーブルで表示していた。そのため曲情報 Array をそのまま React コンポーネントに反映するだけの単純処理で済む。しかし今回はアーティスト、アルバムという親子関係がある。よって曲情報の階層化が必要。
階層化にあたり IndexedDB の保存形式から見直すべきか迷った。従来式だと曲情報の Array
が保存されている。これを [Artists, Albums, Musics]
のようにするべきか否か。
変更した場合は IndexedDB 管理まわりを相当に変更することとなる。そのうえで Store の対応も必要。ならば IndexedDB はそのままとして読み込んだ Array
を動的に階層化したほうがよいだろうと判断した。
アーティストのソートにおける定冠詞
iTunes などの音楽プレーヤーを見ると、アーティスト名のソートにおいて定冠詞が考慮されている。つまり、
- The BEATLES
- The Who
というアーティストならば、定冠詞 the を除外した名前で判定され、
- BEATLES
- Who
と扱われ比較されるようだ。定冠詞の大文字・小文字は無視。サンプルでもこれを参考に比較関数を実装してみた。
export default class Artist {
static compare( a, b ) {
const nameA = a.name.toLowerCase().replace( 'the ', '' );
const nameB = b.name.toLowerCase().replace( 'the ', '' );
return ( nameA === nameB ? 0 : ( nameA < nameB ? -1 : 1 ) );
}
}
ディスク番号によるアルバム内の分類
アルバムの元がレコードや CD 媒体として流通していた場合、複数枚で構成されている可能性がある。これはどのように扱うべきだろうか。
レコード時代には A 面と B 面でコンセプトを変えるものもあった。有名なものだと Queen の 2nd における White/Black side とか。CD の時代になっても The Smashing Pumpkins の Mellon Collie and the Infinite Sadness みたいな複数枚のコンセプト アルバムが作られている。
こうした意図を汲むならばディスク単位でアルバム表示することになる。曲の所属するディスクごとにジャケット画像を変えている可能性もあり、それを判定できる。
しかし今回は iTunes と同様にアルバム内でディスク番号を分類することにした。アルバムとして分けてしまうと曲の追加と削除処理が面倒というのが、その理由。この処理は以下のように実装した。
export default class AlbumList extends React.Component {
_renderMusics( musics ) {
const currentMusic = this.props.context.musicListStore.currentMusic;
const currentPlay = this._getCurrentPlay();
// Group by disc number
const discs = {};
musics.forEach( ( music ) => {
if( discs[ music.disc ] === undefined ) {
discs[ music.disc ] = [];
}
discs[ music.disc ].push( music );
} );
// Multi disc
const keys = Object.keys( discs );
if( 1 < keys.length ) {
const results = [];
keys.forEach( ( key ) => {
results.push( (
<div key={ key } className="album-list__item__body__disc">
Disc { key }
</div>
) );
discs[ key ].forEach( ( music ) => {
results.push( this._renderMusic( music, currentMusic, currentPlay ) );
} );
} );
return results;
}
// Single disc
return musics.map( ( music ) => {
return this._renderMusic( music, currentMusic, currentPlay );
} );
}
}
曲情報 Array
をディスク番号でグループ化。ディスク番号が Key となる連想配列を生成するような感じ。この Key 単位でループさせることでディスク番号の階層を表示している。
なお、曲情報 Array
は以下の比較関数でソート済み。優先度はディスク番号、トラック番号となる。これを前提とすることで、グループ化されたディスク内でトラック番号の順に並ぶ。
export default class Music {
static compare( a, b ) {
if( a.disc !== b.disc ) {
return ( a.disc < b.disc ? -1 : 1 );
}
return ( a.track === b.track ? 0 : ( a.track < b.track ? -1 : 1 ) );
}
}
userData のディレクトリ名
Electron の userData となるディレクトリ名はどのように決定されるのだろう?electron/app.md の app.getPath(name)
欄をみると以下のように書いてある。
The directory for storing your app's configuration files, which by default it is the appData directory appended with your app's name.
"your app's name" というのは package.json
の name
フィールドに指定された値になるようだ。この package.json
はビルドで組み込んだものか electron-prebuilt に指定されたパス直下にあるものになる。
今回のサンプルでは開発とアプリ用の package.json
を分けているため後者となる src/package.json
が該当する。このファイルの name フィールドを書き換えてアプリを再起動したところ、その名前のディレクトリ内にデータが保存された。
では name
に設定した値が既存アプリと衝突した場合はどうなるのか。実行してみたところ既存アプリのディレクトリと結合された。設定した名前を本サンプルの元になった NW.js の音楽プレーヤーと同じにしてみたら、その IndexedDB の内容がそのまま読み込まれてしまった。
つまりアプリのデータを相互に破壊する危険性がある。これはてっきり UUID や GUID なりの一意な識別子で衝突回避しているのだと想像していたけど、そんなことはなかった。衝突したことの検知や対策は用意されておらず自衛するしかないようだ。
もし自衛するとしたら name
フィールドには Java パッケージ名のように me.akabeko.audioplayer
とでも指定しておくのがよいだろう。自身の Web サイトなどで取得しておきたドメインを逆順にしてアプリ名と結合する。これでも不安な場合は UUDI や GUID を指定しておく。
サンプル プロジェクト
今回実装したサンプルを以下に公開。新機能が実装されたのは v1.0.1 以降となる。