🎙️

macの通知を冥鳴ひまりさんに読み上げてもらいたい

2022/12/01に公開

paiza株式会社のエンジニアのもじゃです。この投稿は paiza Advent Calendar 2022 1日目の記事になります。

公開日1週間前の時点でネタも決まってなかったのに何も考えずにノリだけで1日目を取ったのめちゃめちゃ後悔した(ギリギリ間に合った)

TL;DR

  • macの通知はsqliteのDBに平文のbplistで蓄積されるので、適当に読み出してparseすれば活用できる

開発環境

この記事の開発環境です。

MacBook Air 2022:
  CPU: Apple M2
  RAM: 16GB
  OS: macOS 12.6 21G115 arm64

Node.js:
  VERSION: 16.17.0

通知を読むのがめんどい

私は普段から出来得る限り通知を認知したいので、基本的に通知機能を切りません。これは本来得られるはずだった情報を得る機会を失うのが怖いからです。あとは単に「通知が来たときに確認しておかないとそのまま忘れる」というのもあります。

ただし通知を確認すること自体は結構高コストで、作業中に通知が来た場合には一旦手を止めて視線を通知エリアに動かす必要があり、場合によっては通知全容を確認するために追加操作を行わなければなりません。一旦開発作業から思考を剥がすことになるので、この時点で集中力も途切れてしまいます。

そもそも集中したいときには「集中モード」などで通知を切ってしまえばいいのですが、先の通り私はあまりこの選択肢を取る気はありません。

音声による通知の読み上げ

ここで考えました。

視線を動かし、手を止め、操作をする必要があるせいで集中力が途切れるのであれば、逆に今行っている行動を継続したまま通知の内容を得ることができればよいのでは。

たとえばアプリケーションの開発中は「目」「手」は使っていますが、「耳」は使っていません。作業用BGMのように音声として情報を得ることができれば、集中を削ぐこともなく対応の必要性の有無を判断することができます。

ということで、今回はmacOSにおける音声による通知の読み上げを実装します。

macOSの通知を取得する

macOS上で発行された通知はすべてシステム上のSQLite3のデータベースに記録されているようです。

このSQLite3データベースファイルは $TMPDIR/../0/com.apple.notificationcenter/db2/db に配置されています。

$ sqlite3 $TMPDIR/../0/com.apple.notificationcenter/db2/db
SQLite version 3.37.0 2021-12-09 01:34:53
Enter ".help" for usage hints.
sqlite> .scheme
Error: unknown command or invalid arguments:  "scheme". Enter ".help" for help
sqlite> .scheme
sqlite> .schema
CREATE TABLE dbinfo (key VARCHAR, value VARCHAR);
CREATE TABLE app (app_id INTEGER PRIMARY KEY, identifier VARCHAR, badge INTEGER NULL);
CREATE TABLE record (rec_id INTEGER PRIMARY KEY, app_id INTEGER, uuid BLOB, data BLOB, request_date REAL, request_last_date REAL, delivered_date REAL, presented Bool, style INTEGER, snooze_fire_date REAL);
CREATE TABLE requests (app_id INTEGER PRIMARY KEY, list BLOB);
CREATE TABLE delivered (app_id INTEGER PRIMARY KEY, list BLOB);
CREATE TABLE displayed (app_id INTEGER PRIMARY KEY, list BLOB);
CREATE TABLE snoozed (app_id INTEGER PRIMARY KEY, list BLOB);
CREATE TABLE categories (app_id INTEGER PRIMARY KEY, categories BLOB);
CREATE TRIGGER app_deleted AFTER DELETE ON app
BEGIN
    DELETE FROM record WHERE app_id=old.app_id;
    DELETE FROM requests WHERE app_id=old.app_id;
    DELETE FROM delivered WHERE app_id=old.app_id;
    DELETE FROM displayed WHERE app_id=old.app_id;
    DELETE FROM snoozed WHERE app_id=old.app_id;
    DELETE FROM categories WHERE app_id=old.app_id;
END;
sqlite>

(命名が複数形だったり単数形だったり、よくわからない短縮語だったりするのが気持ち悪い)

詳細は割愛しますが(興味があればレコードを参照してみてください)、ここで重要になるのは record テーブルです。今回必要とする通知の情報はこの record テーブルからすべて得られます。

recordテーブルのスキーマ

スキーマは以下のとおりです

CREATE TABLE record (
  rec_id INTEGER PRIMARY KEY,
  app_id INTEGER,
  uuid BLOB,
  data BLOB,
  request_date REAL,
  request_last_date REAL,
  delivered_date REAL,
  presented Bool,
  style INTEGER,
  snooze_fire_date REAL);

data カラムがBLOB型になっていますが、ここにはbplistの形式で通知情報が記録されています。

bplistはmacOSなどのプロパティフォーマットであるplistをバイナリにしたもので、暗号化されている場合と平文の場合があるようですがここでは平文のまま記録されていました。(通知の内容ってコンテンツとしてはセンシティブな気もするけどいいのか...?)

プロパティリスト - Wikipedia

recordテーブルのdataカラムから通知内容を読み出す

先の通り、recordテーブルのdataカラムにbplistの値として通知の内容が記録されていることがわかったので、これを読み出してみます。

今回はnode.jsで実装し、bplistの読み込みには bplist-parser を利用します。

# 作業ディレクトリの作成
$ mkdir mac-notification
$ cd mac-notification

# npm packageの初期化
$ yarn init -y

# 依存packageの解決
$ yarn add sqlite3 bplist-parser

通知DBのrecordテーブルから最新1件を取得するコードを書きます。

main.js

"use strict";

const os = require("os");
const path = require("path");
const sqlite3 = require("sqlite3");
const bplist = require("bplist-parser");

// 通知DBへのpath
const dbPath = path.join(
  os.tmpdir(),
  "..",
  "0",
  "com.apple.notificationcenter",
  "db2",
  "db"
);

// READ_ONLYで通知DBに接続
const db = new sqlite3.Database(dbPath, sqlite3.OPEN_READONLY, (err) => {
  // error handling
  if (err) {
    console.error(err.message);
    system.exit(1);
  }
});

// recordテーブルから最新のレコード1件を取得
db.get("SELECT * FROM record ORDER BY rec_id DESC", (err, row) => {
  // error handling
  if (err) {
    console.error(err.message);
  }

  // bplistをparseしてObjectに変換
  const data = bplist.parseBuffer(row.data)[0];
  console.log(data);
});

コードの実行

$ node main.js

以上の通り、実行するとmacOS上の通知が得られました。

今回はApple Musicの通知なのでこの形式ですが、これはアプリケーションや通知の種類によって中身が異なるようです。

{
  styl: 1,
  intl: true,
  app: 'com.apple.Music',
  uuid: <Buffer cf 98 07 b9 fb 0d 4d 87 ba eb f0 0b 0d c5 f1 ff>,
  date: 690715436.944072,
  srce: <Buffer 08 e3 19 b6 ad df 4f 7f a1 3b 98 47 4c cb d6 49>,
  req: {
    atta: [ [Object] ],
    dest: 15,
    smac: 0,
    subt: 'ノクチル — いつだって僕らは - Single',
    usda: <Buffer 62 70 6c 69 73 74 30 30 d4 01 02 03 04 05 06 07 0a 58 24 76 65 72 73 69 6f 6e 59 24 61 72 63 68 69 76 65 72 54 24 74 6f 70 58 24 6f 62 6a 65 63 74 73 ... 561 more bytes>,
    cate: 'plpl_category',
    titl: 'いつだって僕らは',
    iden: 'com.apple.Music.player'
  },
  orig: 2
}

ざっと見た感じでは欲しい情報は大抵 app, req.titl, req.subt, req.body に入っていたので、これ以上深追いはしないでおきます。

通知のポーリング

先のコードを変更して、レコードの追加を監視することで新しい通知を順次取得できるようにします。

   }
 });
 
-// recordテーブルから最新のレコード1件を取得
-db.get("SELECT * FROM record ORDER BY rec_id DESC", (err, row) => {
+// 最新1件のレコードを取得してidを保持
+let cur;
+db.get("SELECT rec_id FROM record ORDER BY rec_id DESC LIMIT 1", (err, row) => {
   // error handling
   if (err) {
     console.error(err.message);
   }
 
-  // bplistをparseしてObjectに変換
-  const data = bplist.parseBuffer(row.data)[0];
-  console.log(data);
+  cur = row.rec_id;
 });
+
+// 1000msごとにDBに問い合わせて新しいレコードがあったら出力
+setInterval(() => {
+  // recordテーブルから新しいレコードを取得
+  db.each(`SELECT * FROM record WHERE rec_id > ${cur};`, (err, row) => {
+    // error handling
+    if (err) {
+      console.error(err.message);
+    }
+
+    // bplistをparseしてObjectに変換
+    const data = bplist.parseBuffer(row.data)[0];
+
+    console.log(data);
+  });
+}, 1000);

これで継続して通知を取得することができるようになりました。

ポーリング間隔は1000msですが、実際こんな高頻度に叩きに行く必要はないと思うので適当に調整してください。

音声読み上げの準備

通知内容が取得できるようになりましたが、現状のままではデータ構造として読み上げに適していません。

これを「セリフ」として違和感なく読み上げられる形へ整形する必要があります。

英単語のカタカナ変換

今回利用する音声合成ソフトウェアはあまり英語の読み上げに強くないので、事前に英単語をカタカナに変換することで簡易的にカバーします。カバーしきれないIT用語や固有名詞などは個別に辞書として登録していく想定です。

今回はこちらを用いて英単語をカタカナに変換します。

Lingua::JA::Yomi

perlモジュールとして公開されていますが、この内の辞書部分のみを利用します。

# カタカナ変換辞書の取得
$ curl https://fastapi.metacpan.org/source/MASH/Lingua-JA-Yomi-0.01/lib/Lingua/JA/bep-eng.dic -O

これをスクリプトから読み出します。

const os = require("os");
 const path = require("path");
+const fs = require("fs");
 const sqlite3 = require("sqlite3");
 const bplist = require("bplist-parser");

+// 辞書の読み込み
+const dict = {};
+fs.readFileSync(path.join(__dirname, "bep-eng.dic"), "utf8")
+  .split("\n")
+  .forEach((line) => {
+    // NOTE: ライセンスは読み飛ばす
+    if (!line.match(/^#/)) {
+      const [key, value] = line.split(" ");
+      dict[key] = value;
+    }
+  });
+

アプリケーションごとの読み上げテキストの生成

先の通り、bplistに格納されている通知内容はアプリケーションによって微妙に差異があります。また、アプリケーションによっては一切読み上げる必要のない通知もあります。

これを吸収するためにアプリケーションを識別して個別にテキストを生成する処理を実装します。

+    let rawPrompt;
+    switch (data.app) {
+      case "com.hnc.Discord":
+        const author = data.req.titl.match(/^(.+?)\s\(/)[1];
+        rawPrompt = `${author}からDiscordです。${data.req.body}`;
+        break;
+      case "jp.naver.line.mac":
+        rawPrompt = `${data.req.titl}からLINEです。${data.req.body}`;
+        break;
+      case "com.apple.Music":
+        rawPrompt = `なうぷれいんぐ。${data.req.titl}`;
+        break;
+    }
+
+    // 辞書にある単語を置換
+    const prompt = rawPrompt.replace(/[a-zA-z]{2,}/g, (match) => {
+      const kana = dict[match.toUpperCase()];
+      return kana ? kana : match;
+    });
+
     console.log(data);
+    console.log({ prompt });

     // curを更新
     cur = row.rec_id;

今回はサンプルとしてDiscord、LINE、Musicのテキスト生成を実装しました。対象にしたいアプリに合わせた整形処理を追加していくことで任意の通知に対する読み上げを実装できます。

これで(ある程度)読み上げに適したセリフを生成できるようになりました。

利用した辞書の都合で英単語の読みがネイティブ発音寄りですが(ボットゥ、メッセイジ など)、案外これは読み上げに悪く影響はしないようでした。

音声読み上げの実装

ここまでで読み上げを行うのに適した形式で通知を取得できるようになったので、実際に音声合成ソフトウェアに引き渡して読み上げを行っていきます。

macOS標準のsayコマンドによる読み上げ

まずはイメージの確認のため、シンプルにmacOS標準のsayコマンドで音声読み上げを行います。

execSyncで同期的にsayコマンドを呼び出し、再生を行います。

なお、ここではpromptは で囲うことで特殊文字がshellに解釈されることを避けていますが、チャット系アプリからの通知などで改行が含まれる場合はうまく機能しないのでshellに対して適切にエスケープしてください。(行末に \ を付与して改行させるなど)

 const os = require("os");
 const path = require("path");
 const fs = require("fs");
+const { execSync } = require("child_process");
 const sqlite3 = require("sqlite3");
 const bplist = require("bplist-parser");
     // promptが存在する場合は音声読み上げを行う(
     if (prompt !== undefined) {
-      execSync(`say '${prompt}'`);
+      execSync(`say -v kyoko '${prompt}'`);
     }

     // curを更新

https://twitter.com/s10akir/status/1596582576294367232

イントネーションが微妙なのは置いておいて、とりあえず目指すところとしてはいい感じです。

VOICEVOXによる音声読み上げ

sayコマンドの読み上げでも求めている機能は実現できましたがあんまりモチベーションが上がらないボイスなので、VOICEVOXを利用して冥鳴ひまりさんのボイスに差し替えます。

VOICEVOXと冥鳴ひまりさんについての詳細は割愛しますが、端的には無料で利用できる音声合成ソフトウェアとそこから利用できる音声ライブラリ(および音声モデルのサンプル元になったVtuber)です。

https://voicevox.hiroshiba.jp/

VOICEVOXはエンジン部分が切り出されてHTTPサーバとして公開されており、これを用いることで簡単にアプリケーションに組み込む事ができます。

https://github.com/VOICEVOX/voicevox_engine

ここではVOICEVOXの扱い方については割愛します。公式ドキュメント等を参照してください。

VOICEVOXの構築についても割愛しますが、Dockerイメージが公開されているのでそれを利用するとお手軽です。

M2 Macbook Air + Docker for Macではうまく動かすことが出来なかった(10分以上待っても音声合成のレスポンスが帰ってこない)ので、常駐しているLAN内の別サーバに構築しています。おそらくDocker Imageがx86のものなのでARMに最適化されていないためだと思います。Intel Macでは正常に動作しました。

VOICEVOX engineの環境は構築できているものとして、VOICEVOXを利用して音声合成を行い読み上げができるように実装を追加します。VOICEVOX engineとはHTTPでやり取りを行うので、HTTPクライアントにaxiosを利用します。

# axiosを依存に追加
$ yarn add axios
 const os = require("os");
 const path = require("path");
 const fs = require("fs");
 const { execSync } = require("child_process");
 const sqlite3 = require("sqlite3");
 const bplist = require("bplist-parser");
+const axios = require("axios");
+// 別サーバに設置しているので環境変数からエンドポイントを指定する
+const VV_ENDPOINT = process.env.VV_ENDPOINT || "http://localhost:50021";

 // 辞書の読み込み
 const dict = {};
 // promptが存在する場合は音声読み上げを行う(
     if (prompt !== undefined) {
-      execSync(`say '${prompt}'`);
+      // VOICEVOXのクエリを生成 speaker=14: 「冥鳴ひまり」
+      axios
+        .post(`${VV_ENDPOINT}/audio_query?speaker=14&text="${prompt}"`)
+        .then((queryRes) => {
+          // VOICEVOXで音声合成
+          axios
+            .post(`${VV_ENDPOINT}/synthesis?speaker=14`, queryRes.data, {
+              responseType: "arraybuffer",
+            })
+            .then((res) => {
+              // responseをwaveファイルとして一時保存してafplayで再生
+              fs.writeFileSync("/tmp/voice.wav", res.data);
+              execSync("afplay /tmp/voice.wav");
+            });
+        });
     }

https://twitter.com/s10akir/status/1596583270283902976

これで冥鳴ひまりさんのボイスで通知を読み上げてもらえるようになりました。うれしい。

通知を掴んでから音声が再生されるまでにそこそこ時間がありますが、これは単純にVOICEVOXを動かしているサーバのリソースをかなり絞っているためです。潤沢なリソースを割りあてたりGPUを利用することでレスポンスを早めることもできますが、そもそも通知の読み上げにそこまでシビアなリアルタイム性を求めているわけではないのでこのまま運用しています。

英単語同士の間が長いのも違和感がありますが、このあたりについては英単語の置換時に空白を削除してから連結したり、VOICEVOX側のプリセットで単語間ポーズ時間を調整したりなどしていくことで詰めていけると思います。今後運用しつつ気になったところを随時調整していく予定です。

今回は割と技術的検証の面が大きかったのでざっくり雑に実装しましたが、求めているものが実現できることがわかったので時間のあるときに改めてアプリケーションとして丁寧に実装しなおしてみます。

公式で外部APIが公開されているA.I.VOICEなんかも音声合成の品質が高く、しかも英語もそこそこそれっぽく発音してくれるので読み上げ部分を差し替えて使ってみてもいいかもしれませんね。

なお、今回は音声読み上げという形で通知内容を利用しましたが、SQLiteから取得できる通知データはほかにも別端末にテキストとして転送してリモートから確認したりなどアイディア次第で色々活用の余地はあると思います。

完成したスクリプト

main.js

"use strict";

const os = require("os");
const path = require("path");
const fs = require("fs");
const { execSync } = require("child_process");
const sqlite3 = require("sqlite3");
const bplist = require("bplist-parser");
const axios = require("axios");

const VV_ENDPOINT = process.env.VV_ENDPOINT || "http://localhost:50021";

// 辞書の読み込み
const dict = {};
fs.readFileSync(path.join(__dirname, "bep-eng.dic"), "utf8")
  .split("\n")
  .forEach((line) => {
    // NOTE: ライセンスは読み飛ばす
    if (!line.match(/^#/)) {
      const [key, value] = line.split(" ");
      dict[key] = value;
    }
  });

// 通知DBへのpath
const dbPath = path.join(
  os.tmpdir(),
  "..",
  "0",
  "com.apple.notificationcenter",
  "db2",
  "db"
);

// READ_ONLYで通知DBに接続
const db = new sqlite3.Database(dbPath, sqlite3.OPEN_READONLY, (err) => {
  // error handling
  if (err) {
    console.error(err.message);
    system.exit(1);
  }
});

// 最新1件のレコードを取得してidを保持
let cur;
db.get("SELECT rec_id FROM record ORDER BY rec_id DESC LIMIT 1", (err, row) => {
  // error handling
  if (err) {
    console.error(err.message);
  }

  cur = row.rec_id;
});

// 5秒ごとにDBに問い合わせて新しいレコードがあったら出力
setInterval(() => {
  // recordテーブルから新しいレコードを取得
  db.each(`SELECT * FROM record WHERE rec_id > ${cur};`, (err, row) => {
    // error handling
    if (err) {
      console.error(err.message);
    }

    // bplistをparseしてObjectに変換
    const data = bplist.parseBuffer(row.data)[0];

    let rawPrompt;
    switch (data.app) {
      case "com.hnc.Discord":
        const author = data.req.titl.match(/^(.+?)\s\(/)[1];
        rawPrompt = `${author}からDiscordです。${data.req.body}`;
        break;
      case "jp.naver.line.mac":
        rawPrompt = `${data.req.titl}からLINEです。${data.req.body}`;
        break;
      case "com.apple.Music":
        rawPrompt = `なうぷれいんぐ。${data.req.titl}`;
        break;
    }

    // 辞書にある単語を置換
    const prompt = rawPrompt.replace(/[a-zA-z]{2,}/g, (match) => {
      const kana = dict[match.toUpperCase()];
      return kana ? kana : match;
    });

    console.log(data);
    console.log({ prompt });

    // promptが存在する場合は音声読み上げを行う(
    if (prompt !== undefined) {
      // VOICEVOXのクエリを生成
      // speaker=14: 「冥鳴ひまり」
      axios
        .post(`${VV_ENDPOINT}/audio_query?speaker=14&text="${prompt}"`)
        .then((queryRes) => {
          // VOICEVOXで音声合成
          axios
            .post(`${VV_ENDPOINT}/synthesis?speaker=14`, queryRes.data, {
              responseType: "arraybuffer",
            })
            .then((res) => {
              // responseをwaveファイルとして一時保存してafplayで再生
              fs.writeFileSync("/tmp/voice.wav", res.data);
              execSync("afplay /tmp/voice.wav");
            });
        });
    }

    // curを更新
    cur = row.rec_id;
  });
}, 1000);

package.json

{
  "name": "mac-notification",
  "version": "1.0.0",
  "main": "main.js",
  "dependencies": {
    "axios": "^1.2.0",
    "bplist-parser": "^0.3.2",
    "sqlite3": "^5.1.2"
  }
}

ちなみに明日の記事は paizaラーニング 学生スタッフの Ryusei Ishikawa さんによる 任意prefixのドメイン(Vanity Address)でOnionサーバを構築する です。お楽しみに。

https://adventar.org/calendars/8068

Discussion