Electronのrenderer processで、consoleのログ出力をターミナルに吐かせる

さいしょに

Electronアプリの開発中はelectronコマンドで直接アプリを起動すること多いと思う。

electron .
  • この時、main processでconsole.logると、ターミナルにログが出力される。
  • でも、renderer processでconsole.logしたら、ブラウザ側にログが出力される。


見る場所が分散していると面倒ですね。
だから、一カ所にまとめちゃおう。
両方ともターミナルにログ出力して貰おう、という話。

consoleの差し替え

renderer process内で、consoleを差し替えれば良い。

console = require('electron').remote.require('console');

console.log('log from renderer process');

おわり

おわり。
本番アプリではconsoleのコードは取り除かれるだろうから、開発中の話。
global変数のconsoleを差し替えると、eslintの推奨設定だと怒られちゃうけどね!

Web Audio APIでOfflineAudioContextを作るときの長さの指定、どうしよう

OfflineAudioContextのコンストラク

Web Audio APIのOfflineAudioContextを作るとき、
コンストラクタで長さを指定しますよね。length。

  • これが十分な長さより短いと作成した音声が短くなってしまい、
  • 長すぎると最後の方に音が出力されないゾーンが出来てしまう。
const offlineCtx = new OfflineAudioContext(numberOfChannels, length, sampleRate);


けれど、いつもいつも事前に適切な長さが分かることばかりではない。
特に、Web Audio APIで音声を加工するのであれば。
例えば、playbackRateを低くしたりすると、音声が長くなってしまいますよね。


(加工後の音声の秒数でも分かれば計算で出せるけど、
音声を加工するにはOfflineAudioContextを作る必要があって、
音声を加工する前にlengthの指定が必要なんですね)

どうしよう

これで良いのか分からないけど、
自分の対応はこうしてみた。

  • でっかく作って (短いと加工した音声が入らなくなるのだから当然だ)
  • 余分な部分を切り詰めよう

あ、これ、今更だけど、オフラインに音声ファイルを出力するケースを想定した考えかもしれない。

長いOfflineAudioContextを作る

これは簡単ですね。
lengthの数値を大きくすれば良い。

const offlineCtx = new OfflineAudioContext(numberOfChannels, length, sampleRate);

切り詰める

音声が入っている場所までの長さを調べる。

ああ、そうだ。このコードは最終的にWAVファイルの出力を想定しているので、
データのサイズが偶数である必要があって、長さを計測する際、少し調整が入っているよ。

function correctFrameCount(audioBuffer: AudioBuffer): number {
  let max = 0;
  for (let i = 0; i < audioBuffer.numberOfChannels; i++) {
    const buffer: Float32Array = audioBuffer.getChannelData(i);
    const count = correctBufferLength(buffer);
    if (max < count) {
      max = count;
    }
  }
  return max;
}
function correctBufferLength(buffer: Float32Array): number {
  let pos = 0;
  for (let i = buffer.length - 1; i >= 0; i--) {
    if (buffer[i] !== 0x00) {
      pos = i;
      break;
    }
  }
  if (pos % 2 != 0) {
    pos += 1;
  }
  return pos;
}

そして、測った長さにAudioBufferを切り詰める。

function buildCorrectAudioBuffer(audioBuffer: AudioBuffer): any {
  const frameCount = correctFrameCount(audioBuffer);
  const nAudioBuffer = new AudioBuffer({
      numberOfChannels: audioBuffer.numberOfChannels,
      length: frameCount,
      sampleRate: audioBuffer.sampleRate,
  });

  for (let i = 0; i < audioBuffer.numberOfChannels; i++) {
    const buffer = audioBuffer.getChannelData(i);
    const trimmed = buffer.slice(0, frameCount);
    nAudioBuffer.copyToChannel(trimmed, i, 0);
  }
  return nAudioBuffer;
}
const newAudioBuf = buildCorrectAudioBuffer(audioBuf);

おわり

おわり。

Electronでネイティブを実行できるnode-ffiを利用できるようになるまで

はじまりはじまり

node-ffiというライブラリがある。
これを使うと、ネイティブなライブラリ・フレームワーク
JavaScriptのコードから呼び出せる。
https://github.com/node-ffi/node-ffi


なのですが、Electronから使う場合、npmでnode-ffiを入れるだけでは、
動作しないことがほとんどなのであった。
↓のようなエラーが出たり(もちろんネイティブは呼び出せない)、そもそもElectronアプリが起動しなかったり。

was compiled against a different Node.js version using
NODE_MODULE_VERSION 64. This version of Node.js requires
NODE_MODULE_VERSION 57. Please try re-compiling or re-installing
the module (for instance, using `npm rebuild` or `npm install`).
    at process.module.(anonymous function) [as dlopen] (ELECTRON_ASAR.js:172:20)
    at Object.Module._extensions..node (module.js:598:18)
    at Object.module.(anonymous function) [as .node] (ELECTRON_ASAR.js:186:18)
    at Module.load (module.js:503:32)
    at tryModuleLoad (module.js:466:12)
    at Function.Module._load (module.js:458:3)
    at Module.require (module.js:513:17)
    at require (internal/module.js:11:18)

ちなみに環境はMacですぞ。

どうするどうする?

こうすれば絶対大丈夫、というのは、まだよく分からない。
何度か同じ問題と戦っているけど、
一度成功するパターンを掴んだら、そこで問題の追跡止めちゃうからね。仕方ないね。


以下は、最新の成功パターンである。

バージョンを決める

いろいろな理由があって、入れるバージョンは、このバージョンになった。
Electron 3系は、Nodeは10.2。
Electron 3なので、Spectron 5.0。
electron-packagerは12.1.0以降でないとダメ。

  • Electron 3.1.1
  • Node 10.2.1
  • Spectron 5.0.0
  • electron-packager 12.1.0

node 10.2.1を入れる

nvmでnodeをインストールする。

nvm ls-remote
nvm install v10.2.1
nvm use v10.2.1

npmでelectronを入れる

electron-packagerもついでに入れる。

npm install --save-dev electron@3.1.1 spectron@5.0.0 electron-rebuild electron-packager@12.1.0

node-ffi関連のライブラリを入れる

ffiを使うには、natives、bindings、debug、ms、nan、ref、ref-structあたりが必要になる。
bindings、ms、debugは直接入れなくても平気ではないか説ある。
(でも、今試しにbindings、ms、debug抜いたら、ビルドエラーになっちゃった)

npm install --save natives bindings debug ffi ms nan ref ref-struct

TypeScriptを使っているなら、定義ファイルも入れておきましょう。

npm install --save-dev @types/ffi @types/ref @types/ref-struct

ffi関連ライブラリをビルドする

electron-rebuildコマンドでnodeのライブラリをビルドする。
環境構築が上手く行かないときは、ここのビルドが失敗することが多いと思う。

./node_modules/.bin/electron-rebuild

動作確認

これでElectronアプリが起動して、
かつ、変なエラーログが出ていなければ、成功です。
Let's enjoy node-ffi.

./node_modules/.bin/electron .

おわり

おわり。

続・Electronの検索ダイアログ

Electronのページ内検索ダイアログのライブラリを作り替えた。

electron-search-dialog
https://www.npmjs.com/package/electron-search-dialog

すごいシンプルになった

作り替えたら、renderer processのjavascriptから、
このコードを呼ぶと動くようになった。簡単。
テキトーに作ってたことがバレたなガハハ!

var SearchDialog = require('electron-search-dialog').default;
 
// create instance.
var mainWindow = require('electron').remote.getCurrentWindow();
var sd = new SearchDialog(mainWindow);
 
// open search dialog
sd.openDialog();

おわり

おわりだどん。

ElectronのBrowserWindowを閉じた時に、closeでなく非表示にして、次回の再表示を早くする

ElectronのBrowserWindowのインスタンスを画面表示のたびに作り直すのは重い。


ので、閉じたり開いたりを繰り返す、サブのウィンドウでは、

  • BrowserWindowを破棄して、再度作るのではなく
  • いったん隠して、必要になったらウィンドウを再表示する

ように変えると少し早くなる。

実装

実現するには、closeイベントでevent.preventDefault()しておいて、
BrowserWindowをhide()すれば良い。

subwindow.on('close', (event) => {
  event.preventDefault();
  subwindow.hide();
});


ウィンドウ表示部分のコードはこうなった。

var mainwindow = ...;
var subwindow;

function showSubWindow(): void {
  if (subwindow && !subwindow.isDestroyed()) {
    subwindow.show();
    subwindow.focus();
    return;
  }

  subwindow = new BrowserWindow({
    parent: mainwindow,
    show: false
  });
  subwindow.loadFile('./subwindow.html');

  subwindow.webContents.on('did-finish-load', () => {
    subwindow.show();
    subwindow.focus();
  });
  subwindow.on('close', (event) => {
    event.preventDefault();
    subwindow.hide();
  });

  subwindow.on('closed', () => {
    subwindow = null;
  });
}

BrowserWindowの初期化処理が走らなくなるので

この実装だと、BrowserWindowの再表示時に初期化処理が走らない、
テキストフィールドへのautofocusとか動作しないので、

<!DOCTYPE html>
<html>
<head>
</head>
<body>
  <form>
    <label>検索する文字列:</label>
    <input id="search-text" class="form-control" type="search" autofocus>
  </form>
</body>
</html>

うまいこと誤魔化す必要があると思う。
こんな感じに。

window.onfocus = function(){
  document.getElementById('search-text').focus();
}

終わり

おわりじゃ!

Electron用の検索ダイアログ作った

つくった

Electronアプリのページ内を検索するダイアログ。
https://www.npmjs.com/package/electron-search-dialog

使うと、こんな感じに検索ウィンドウが表示されて、
Electronのページのテキストを検索できる。
Excelの検索ダイアログに似せてみた。


npmにpublishしたので、npm installで利用できる

npm install --save electron-search-dialog


localeの設定が違えば、検索ダイアログの言語も変わる。
アメリカ語と、日本語しかサポートしてないけどね。

使い方

  • ライブラリをインストールする
npm install --save electron-search-dialog
  • 検索するウィンドウを指定して、インスタンス生成。
  • openDialog()でウィンドウを表示。
var SearchDialog = require('electron-search-dialog').default;
 
// create instance.
var win = ... // searching page (BrowserWindow)
var sd = new SearchDialog(win);
 
// open search dialog
sd.openDialog();

実際には

実際には検索ダイアログは、画面の操作をトリガーにして開かれるでしょうから、
ipcRendererで起動処理を走らせるような使い方になるでしょう。
サンプルコードがこちらにあります。


  • main.js
var {app, BrowserWindow, ipcMain} = require('electron')
var SearchDialog = require('electron-search-dialog').default;
 
let mainWindow
 
function createWindow () {
  mainWindow = new BrowserWindow({width: 800, height: 600})
  mainWindow.loadFile('index.html')
  mainWindow.on('closed', function () {
    mainWindow = null
  })
 
  // -----------------------------
  // create SearchDialog instance,
  // and add open search dialog event
  // -----------------------------
  var _searchDialog = new SearchDialog(mainWindow);
  ipcMain.on('openSearchDialog', (event, message) => {
    _searchDialog.openDialog();
  });
}
app.on('ready', createWindow)
 
app.on('window-all-closed', function () {
  if (process.platform !== 'darwin') {
    app.quit()
  }
})
app.on('activate', function () {
  if (mainWindow === null) {
    createWindow()
  }
})
  • index.html
<!DOCTYPE html>
<html>
  <body>
    <h1>Hello World!</h1>
    We are using Node.js <script>document.write(process.versions.node)</script>,
    Chromium <script>document.write(process.versions.chrome)</script>,
    and Electron <script>document.write(process.versions.electron)</script>.
    <br>
    <button id="btn">open</button>
 
    <script>
      require('./renderer.js')
    </script> 
  </body>
</html>
  • renderer.js
// -----------------------------
// at search button clicked,
// send open search dialog event.
// -----------------------------
var ipcRenderer = require('electron').ipcRenderer;
 
var btn = document.getElementById('btn');
btn.addEventListener('click', () => {
  ipcRenderer.send('openSearchDialog', 'open');
});

いろいろ

BrowserWindowベースの検索ダイアログは重い

  • それはそうだ。
  • 一回開けば、そこからは表示・非表示の切り替えなので、遅くない。
  • 検索ダイアログのBrowserWindowインスタンスを、余裕のある時にあらかじめ初期化するような作りにしておけば、全然軽く出来そう。

クリアボタン

入力のクリアボタンなんか用意しないでも、
HTML5のsearchボタンを使えば、入力クリアボタンがついてくる。

<input type="search" value="">

なのですが、検索ダイアログの縦幅が小さすぎると、
なんか格好悪くなってしまったので、入力クリアボタン入れてしまった。

Electronでのlocaleの切り替え方法

Electronでlocaleを切り替えるには、こうする。
特にAPIとかないみたい。

var {app} = require('electron')
app.commandLine.appendSwitch('lang', 'en');

おわり

このライブラリを作った理由はだいたいElectronのfindInPageが使いづらいせい。
https://electronjs.org/docs/api/web-contents#contentsfindinpagetext-options

  • 検索ワードを入力したテキストフィールドが検索マッチしたり、
  • 次の検索候補に遷移しなかったり


同じように、Electronで画面の検索を行うライブラリには、
他に、chromeのwebviewを使って検索を実現する electron-in-page-search があります。
https://www.npmjs.com/package/electron-in-page-search


おわりだよ〜

Web Audio APIのOfflineAudioContextを使うたびに増えていく、スレッド数とメモリ使用量の調査

経緯

音声を操作するWeb Audio APIという、WebブラウザAPIがあります。
そのAPIの中の、OfflineAudioContextというオブジェクトを、MacのElectron製アプリで利用していたのですが、
アプリを動作させながらアクティビティモニタを確認していたら、何かがおかしい。おかしい。


何故か、
音声を再生するごとに、
アプリが使用しているスレッドの数が増えていったのです。




↓(何回か再生)

ということがあり、これは困ったぞ、と、いろいろ調べてみたのでありました。

Electron 1.8.8 で検証

Electron バージョン 1.8.8。最初に問題を発見した環境。少し古い。
Electronに組み込まれているChromiumのバージョンは

>process.versions.chrome
"59.0.3071.115"


最小の検証コード(一部)
ボタンを押したら音声を再生する。
https://github.com/taku-o/offlineaudiocontext-memoryleak/blob/master/electron188/renderer.js

var fs = require('fs');
var audioCtx = new window.AudioContext();

function toArrayBuffer(buf) {
    var _aBuffer = new ArrayBuffer(buf.length);
    var view = new Uint8Array(_aBuffer);
    for (let i = 0; i < buf.length; ++i) {
        view[i] = buf[i];
    }
    return _aBuffer;
}

var btn = document.getElementById('play');
btn.addEventListener('click', function(elm, ev) {
    console.log('click play');

    // read
    fs.readFile('./voice.wav', function(err, bufWav) {
        if (err) {
            console.log('failed to read wav. path %s', './voice.wav');
            return false;
        }
        console.log('read wav');

        // decode audio
        var aBuffer = toArrayBuffer(bufWav);
        audioCtx.decodeAudioData(aBuffer).then(function(decodedData) {
            console.log('decode wav');

            // process audio
            var offlineCtx = new OfflineAudioContext(decodedData.numberOfChannels, decodedData.length, decodedData.sampleRate);

            var inSourceNode = offlineCtx.createBufferSource();
            inSourceNode.buffer = decodedData;

            var gainNode = offlineCtx.createGain();
            gainNode.gain.value = 0.4;
            inSourceNode.connect(gainNode);
            gainNode.connect(offlineCtx.destination);

            inSourceNode.start(0);

            // offlineCtx rendering
            offlineCtx.startRendering().then(function(renderedBuffer) {
                console.log('process wav');

                // play
                var audioNode = audioCtx.createBufferSource();
                audioNode.buffer = renderedBuffer;
                audioNode.connect(audioCtx.destination);
                audioNode.start(0);
                console.log('play processed wav');
            });
        });
    });

}, false);
発生した現象

前述した通り、OfflineAudioContextで音声を再生する毎に、動作しているスレッド数が増えていく。
メモリの消費量も徐々に増えていく。




↓(何回か再生)


アクティビティモニタで確認してみると、
"offline audio renderer"という、いかにもOfflineAudioContextに関係ありそうなスレッドが
OfflineAudioContextが不要になった後でも残り続けているようです。
いろいろ試してみましたが、Javascript側からこの問題を解消することはできませんでした。

2550 Thread_13873430: offline audio renderer
  2550 thread_start  (in libsystem_pthread.dylib) + 13  [0x7fffb3b3e08d]
    2550 _pthread_start  (in libsystem_pthread.dylib) + 286  [0x7fffb3b3e887]
      2550 _pthread_body  (in libsystem_pthread.dylib) + 180  [0x7fffb3b3e93b]
        2550 ???  (in Electron Framework)  load address 0x10eaea000 + 0x2640e7  [0x10ed4e0e7]
          2550 ???  (in Electron Framework)  load address 0x10eaea000 + 0x290709  [0x10ed7a709]
            2550 ???  (in Electron Framework)  load address 0x10eaea000 + 0x26f183  [0x10ed59183]
              2550 ???  (in Electron Framework)  load address 0x10eaea000 + 0x25a15c  [0x10ed4415c]
                2550 ???  (in Electron Framework)  load address 0x10eaea000 + 0x25a6bf  [0x10ed446bf]
                  2550 CFRunLoopRunSpecific  (in CoreFoundation) + 420  [0x7fff9e185114]
                    2550 __CFRunLoopRun  (in CoreFoundation) + 1361  [0x7fff9e1858c1]
                      2550 __CFRunLoopServiceMachPort  (in CoreFoundation) + 212  [0x7fff9e186434]
                        2550 mach_msg  (in libsystem_kernel.dylib) + 55  [0x7fffb3a4b797]
                          2550 mach_msg_trap  (in libsystem_kernel.dylib) + 10  [0x7fffb3a4c34a]

Electron 2.1.0 で検証

Electronのバージョンを上げて試してみる。
Electronを2.1.0に上げてみました。

>process.versions.chrome
"61.0.3163.100"


Electron 1.8.8で試した時のように、
音声を再生する毎にスレッド数が増えて残り続けることはなくなりました。
やった!!





しかし、ごっつりメモリを消費するようにもなってしまいました。
ギエエエエエエエ


検証コード
https://github.com/taku-o/offlineaudiocontext-memoryleak/blob/master/electron210/renderer.js

Electron 2.1.0 に終了処理を追加

これには解決方法があります。
使用済みのOfflineAudioContext、AudioContextの終了処理をすれば良いのです。
disconnectとcloseを使えば良い。

(参考) Web Audio APIの闇
https://qiita.com/zprodev/items/7fcd8335d7e8e613a01f

var fs = require('fs');

function toArrayBuffer(buf) {
    var _aBuffer = new ArrayBuffer(buf.length);
    var view = new Uint8Array(_aBuffer);
    for (let i = 0; i < buf.length; ++i) {
        view[i] = buf[i];
    }
    return _aBuffer;
}

var btn = document.getElementById('play');
btn.addEventListener('click', function(elm, ev) {
    console.log('click play');

    // read
    fs.readFile('./voice.wav', function(err, bufWav) {
        if (err) {
            console.log('failed to read wav. path %s', './voice.wav');
            return false;
        }
        console.log('read wav');

        // decode audio
        var audioCtx = new window.AudioContext();
        var aBuffer = toArrayBuffer(bufWav);
        audioCtx.decodeAudioData(aBuffer).then(function(decodedData) {
            console.log('decode wav');

            // process audio
            var offlineCtx = new OfflineAudioContext(decodedData.numberOfChannels, decodedData.length, decodedData.sampleRate);

            var inSourceNode = offlineCtx.createBufferSource();
            inSourceNode.buffer = decodedData;

            var gainNode = offlineCtx.createGain();
            gainNode.gain.value = 0.4;
            inSourceNode.connect(gainNode);
            gainNode.connect(offlineCtx.destination);

            inSourceNode.start(0);

            // offlineCtx rendering
            offlineCtx.startRendering().then(function(renderedBuffer) {
                console.log('process wav');

                // play
                var audioNode = audioCtx.createBufferSource();
                audioNode.buffer = renderedBuffer;
                audioNode.connect(audioCtx.destination);
                audioNode.onended = function(evEnd) {
                  console.log('play processed wav ended');

                  inSourceNode.disconnect();
                  gainNode.disconnect();
                  audioNode.disconnect();
                  audioCtx.close();
                  global.gc();
                  console.log('close audio node');
                };
                audioNode.start(0);
                console.log('play processed wav');
            });
        });
    });

}, false);





メモリの消費量はだいぶ減りました。
音声再生しても、スレッド数も増えません。
しかし、まだまだ音声再生するごとに、メモリを消費してしまいます。


ChromiumのDevelop ToolのHeap Snapshotで、
音声再生前、音声再生後のスナップショットを取り、
どのようなゴミが残ってしまうのかと確認すると、

"Pending activities / 1 entries",
↓
"Pending activities / 2 entries",
↓
"Pending activities / 3 entries",


OfflineAudioContextで音声を再生する毎に、
"Pending activities"というゴミが増え続けていくことがわかりました。
こいつがメモリを掴んでいるのでしょう。多分。きっと。


検証コード
https://github.com/taku-o/offlineaudiocontext-memoryleak/blob/master/electron210close/renderer.js

Electron 3.1.1 で検証

さらにElectronのバージョンを上げる。3.1.1。
ちなみに最新のElectronのバージョンは4であり、まだバージョンを上げる余地がある。
4は新しすぎるので、個人的にはまだちょっと不安ありますけれど。

>process.versions.chrome
"66.0.3359.181"






良くなった。
Heap Snapshotで確認してみたが、
"Pending activities"の数が、音声再生後に増えるようなこともない。
勝ったな!!(なにが?)


まだ少しずつはメモリの消費が増えている。
他にも問題があるのかな?
issueリストの中に該当するものがあるかもしれない。
https://bugs.chromium.org/p/chromium/issues/list?can=2&q=component:Blink%3EWebAudio&sort=-modified&colspec=ID%20Pri%20M%20Stars%20ReleaseBlock%20Component%20Status%20Owner%20Summary%20OS%20Modified


まぁでも、これぐらいなら良いでしょう。
(そ、そうかな?)


検証コード
https://github.com/taku-o/offlineaudiocontext-memoryleak/blob/master/electron311/renderer.js

おわり

以上、今回、見つけた出来事でした。


Webなら、画面をリロードすれば、画面遷移すれば、ブラウザを閉じれば、解決する程度の問題ですが、
Electronの場合は、基本的にSingle Page Applicationで、しかも、アプリは起動され続けるので、
そこそこの問題になってしまうのです。


ひとまず、MacのElectronで、Web Audio APIのOfflineAudioContextを使うなら、バージョン3以降だな!