AquesTalkの機能をJavaScriptから呼び出す機能をライブラリに切り出した

概要

Mac用の動画作成用ゆっくりボイスアプリ MYukkuriVoice で使用している AquesTalk呼び出し機能を、ライブラリに切り出した。
たぶんMacで、Electronで、AquesTalkな読み上げアプリを作るなら役に立つでしょう。
めちゃくちゃレアケースだな!!!

※ MYukkuriVoice
www.nicovideo.jp


でも、ごめん使うのは大変

  • Macでしか動かないのであります
  • お試しするのにも、たくさん準備がいります
    • AquesTalkライブラリが必要
  • これを使って、アプリを作るなら更に準備がいります
    • AquesTalkライブラリの開発ライセンス、使用ライセンスが必要
  • たいへん(^_^;)


使い方

ライブラリの使い方の説明は https://github.com/taku-o/aquestalk-mac に書いてあります。

けれど、このaquestalk-macライブラリを使うにあたって大変なのは、
ライブラリの使い方より、導入までだと思うので、この記事では、その話をする。
おそらく、ライブラリそのものではなく、このサンプルアプリを動かすまでの手順が、このライブラリで一番価値があるものです。


サンプルアプリの動かし方

1. Pythonのバージョンを確認する

Macのデフォルトのsystemバージョンが望ましい。
他のPython環境を入れていてそれを使っていると、npm installで失敗することが多い。

2. npm installで必要なライブラリをインストール

git clone https://github.com/taku-o/aquestalk-player-mac.git
cd aquestalk-player-mac
npm install

3. electron-rebuildでnative系のライブラリを作り直す

electron-rebuildしないと動かない。

./node_modules/.bin/electron-rebuild

4. vendorEvaディレクトリに評価版ライブラリを入れる

  • AquesTalk2 Mac SDK評価版
  • AqKanji2Koe Mac SDK
    • この2つを入手して、"vendorEva"ディレクトリに次のようにファイルを配置する
|-- vendorEva
    |-- AqKanji2Koe.framework
    |-- AquesTalk2Eva.framework
    |-- aq_dic_large/aq_user.dic
    |-- aq_dic_large/aqdic.bin
    `-- phont/aq_f1c.phont

5. サンプルアプリを動かす

これでElectronのサンプルアプリが起動すると思います。
サンプルアプリでは入力した文字列を、AquesTalkを使って音声として再生したり、wavファイルとして保存したりできます。
お疲れ様でした。

cd sample
npm install
npm run start


このライブラリはファイルアクセスが必要?

Q. このライブラリを使用するにはファイルアクセスが必要?
    A. 必要

Q. つまり、Webブラウザでは動作しない?
    A. 基本的には動作しない

Q. 音声をJavaScriptで再生するには、Web Audio APIか、HTML5 Audioクラスあたりが妥当?
    A. 他のプログラムに作成した音声データを渡さないなら妥当

Q. Web Audio APIは、Webブラウザで動作しますよね?
    A. ・・・そうですね

Q. このライブラリはどういう場面で使えるの?
    A. ・・・え、Electronとか・






おまけ

ニコニコのランキングシステム変わるし、
SoftalkとかCeVIOとか、
動画の音声作成ソフトのタグには"テキスピ"使おうぜ、って話が出ている。

www.nicovideo.jp

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


おわりだよ〜