音声で手軽に登録するToDoリストのシステムをつくる

ToDoリストって仕事なら多少面倒でもやるんだけど、

プライベートのちょっとした買いものリストのメモとか、割引券やポイントの有効期限とか、
家庭内でやらないといけないことのToDoリストって、登録すること自体が面倒で結構忘れそうになります。

そこで音声なら即座に手軽に管理できるのではと思い作ってみました。


## 完成品

古いタブレットPCにNotionのTodoリストをブラウザで表示しています。
この白い箱自体がボタンになっており、Arduino経由でタブレットPCのマイクを起動し録音します。
録音されたデータは音声認識をして、Notionにタスクが追加されます。

![IMG_1270.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7132/23522ee0-01b4-8a7b-99da-2ab9150a9e7b.jpeg)

このシステムをリビングとかに置いたら、誰でも気軽に登録できます。


## Notion APIの準備
### Secret Key を作成する
こちらからインテグレーションを作成するとAPIで使うシークレットキーが作成できます。
https://www.notion.so/my-integrations
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7132/5b9f2cf8-0a83-4115-5f61-51d6afdfcebb.png" width=400>

### APIからDB操作できるようにする
APIで操作したいデータベースのページの設定の**コネクト**から先程作成したインテグレーションを追加します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7132/468c6b9c-cb81-9d70-144a-04fb7747889f.png" width=400>

### Notion APIのテスト
`DATABASE_ID`はURLから取り出せます。

```javascript:notion.js
import { Client } from "@notionhq/client";

const SECRET_KEY = "secret_xxxxxxxxxxxxx"
const DATABASE_ID = "xxxxxxxxxx"

const notion = new Client({
  auth: SECRET_KEY,
});

(async () => {
  const response = await notion.databases.query({
    database_id: DATABASE_ID,
  });

  console.log(response);
})();
```

正しく設定できていれば、これを実行するとページ情報が取得できます。
```terminal
$ node notion.js
{
  object: 'list',
  results: [
    {
      object: 'page',
      id: 'xxxxxxxxxx',
      created_time: '2024-05-18T21:47:00.000Z',
      last_edited_time: '2024-05-18T21:47:00.000Z',
      created_by: [Object],
・
・
・
```


## 音声認識APIの準備
今回はクーポンをいただいたので `AmiVoiceAPI` を使っていきます。

https://acp.amivoice.com/amivoice_api/regist/

### APPKEY を取得
アカウントを作成すると、マイページから接続情報で認証情報が取得できます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7132/d972e50b-b74f-d3a5-dd70-80f242d22bd5.png" width=400>
キーの再生成方法がわからなかったので取り扱いは一層の注意が必要そうです。

### AmiVoiceAPIのテスト
```amivoice.js
import fs from 'fs';
import axios from 'axios';
import FormData from 'form-data';

const APP_KEY = "xxxxxxxxxxxxxx"

(async () => {
  const file = fs.readFileSync('test.wav');

  const formData = new FormData();
  formData.append('u', APP_KEY);
  formData.append('d', 'grammarFileNames=-a-general-input');
  formData.append('a', file);

  const response = await axios.post('https://acp-api.amivoice.com/v1/recognize', formData, {
    headers: {
      'Content-Type': `multipart/form-data; boundary=${formData.getBoundary()}`,
    },
  });

  console.log(response.data);
})();
```

適当にwavファイルを録音して、リクエストを送ってみるとしっかりと`こんにちは`と認識してくれました。
```
node amivoid.js
{
  results: [
    {
      tokens: [Array],
      confidence: 0.999,
      starttime: 850,
      endtime: 2338,
      tags: [],
      rulename: '',
      text: 'こんにちは'
    }
  ],
  utteranceid: 'xxxxxxxxxxxxxxx',
  text: 'こんにちは',
  code: '',
  message: ''
}
```

`grammarFileNames=-a-general-input` で音声認識エンジンを指定しています。
今回は`音声入力_汎用`ってやつを使っています。
医療、保険、金融ってのはあったけど、IT系のはない。でも結構専門用語も認識してくれそうな感じでした。


## 音声録音部分の作成
Webブラウザからマイクを録音する部分はこちらの記事を参考にさせてもらいました。
wavファイルを作成する部分までやってくれています。

https://www.s1r-j.tk/Recorderjs/

```html:recording.html
<!DOCTYPE html>
<html>

<body>
  <ul id="list"></ul>

  <script src="./recorder.js"></script>
  <script>
    (async () => {
      const $list = document.querySelector("#list");

      let isRecording = false;
      let recorder;
      let audioContext;

      const startUserMedia = (stream) => {
        audioContext = new AudioContext()
        const input = audioContext.createMediaStreamSource(stream);
        audioContext.resume();

        recorder = new Recorder(input);
        console.log('Recorder initialised.');
      }

      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      startUserMedia(stream);

      const startRecording = () => {
        if (isRecording) return;

        console.log('start recording');
        isRecording = true;
        recorder.record();
      }

      const stopRecording = () => {
        console.log('stop recording');
        isRecording = false;
        recorder.stop();

        voiceRecognize();
        recorder.clear();
      }

      const createDownloadLink = () => {
        recorder.exportWAV((blob) => {
          var url = URL.createObjectURL(blob);
          var $li = document.createElement('li');
          var $audio = document.createElement('audio');

          $audio.controls = true;
          $audio.src = url;

          $li.appendChild($audio);
          $list.appendChild($li);
        });
      }

      document.addEventListener("keydown", (e) => {
        if (e.code === "Space") {
          startRecording();
        }
      });

      document.addEventListener("keyup", (e) => {
        if (e.code === "Space") {
          stopRecording();
        }
      });
    })();
  </script>
</body>

</html>
```

スペースキーを押している最中のみ録音されるように改修しています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7132/ee488773-f73e-65ad-5c19-57691d4e622a.png" width=400>


## 物理ボタンの作成
物理的なボタンがあると手軽さが増すんですよね、大事。

ボタンはArduino Leonardoをキーボードとして実装します。
手法としては昔やったことのあるボタンっぽい箱の底面にタクトスイッチを取り付ける方法。

https://qiita.com/narikei/items/9c77336985fea28fde2a#%E7%89%A9%E7%90%86%E3%83%9C%E3%82%BF%E3%83%B3%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6

<img src="https://qiita-image-store.s3.amazonaws.com/0/7132/10ea7f81-76d0-c74b-95a4-b04b5c62d293.png" width=600>

<img src="https://qiita-image-store.s3.amazonaws.com%2F0%2F7132%2F1a776735-6b09-6de8-bf21-66a92633a62f.jpeg" width=400>

（ボタンは3Dプリンターか何かで作ってしまいたかったけど、手持ちに良い色のフィラメントがなかった・・・。）


スイッチが押されている間、キーボードのスペースキーが押され、声を録音できるようにしています。

```c++
#include <Keyboard.h>

int pin = 8;
bool isPressed = false;

void setup() {
  pinMode(pin, INPUT_PULLUP);
  Keyboard.begin();
}

void loop() {
  if (digitalRead(pin) == LOW) {
    if (isPressed) {
      Keyboard.releaseAll();
    }
    isPressed = false;
  } else {
    if (!isPressed) {
      Keyboard.press(32);
      isPressed = true;
    }
  }
}
```

## 合体させてブラウザ拡張機能にする
登録用のページを作るとviewを作るのが面倒なので、Notionページ上で動くようにします。
また、NotionAPiに関してはCORS制限がかかってたため、フロントエンドから直接投げられなかったためバックエンドで処理を実装しました。

### ブラウザ拡張機能化
長くなりそうなので詳しい作り方は過去の記事を御覧ください！
`matches` に対象のURLを指定するとそのページだけで作ったスクリプトが読み込まれるようになります。

```json:manifest.json
{
  "manifest_version": 3,
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": [
        "https://www.notion.so/xxxxx/xxxxxxxxx"
      ],
      "js": [
        "scripts/voiceTodo.js"
      ]
    }
  ]
}
```


### 合体させる
ブラウザで録音した音声をAmiVoiceAPIに投げるように修正します。

```js:voiceTodo.js
  const APP_KEY = "xxxxxxxxxxxxxx"

  const stopRecording = () => {
    console.log('stop recording');
    isRecording = false;
    recorder.stop();

    voiceRecognize();
    recorder.clear();
  }

  const voiceRecognize = () => {
    recorder.exportWAV(async (blob) => {
      const formData = new FormData();
      formData.append('u', APP_KEY);
      formData.append('d', 'grammarFileNames=-a-general-input');
      formData.append('a', blob, 'audio.wav');

      const response = await axios.post('https://acp-api.amivoice.com/v1/recognize', formData, {
        headers: {
          'Content-Type': `multipart/form-data;`,
        },
      });

      console.log("voiceRecognize", response.data);

      chrome.runtime.sendMessage({
        text: response.data.text,
      });
    });
  }
```

バックエンドではNotionに新しく作成します。

```js:background.js
chrome.runtime.onMessage.addListener((message) => {
  if (message.text) {
    const postNotion = async (text) => {
      const response = await notion.pages.create({
        parent: {
          type: "database_id",
          database_id: DATABASE_ID,
        },
        properties: {
          名前: {
            title: [
              {
                text: {
                  content: text,
                },
              },
            ],
          },
        },
      });
      console.log(response);
    };

    postNotion(message.text);
  }

  return true;
});
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7132/cad6d291-a712-b5b4-b7c8-1d5368eeb584.png" width=400>

## 動かしてみる

これでうまく登録できました！

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7132/0f23e03f-bfdb-824e-74f2-7cd6febf3511.png" width=400>


## おわり
ブラウザ拡張機能で実装したので、普段遣いのPCにも入れて出先でもつかえるのは良いところ。

AIを入れれば、完了操作とかプロパティを細かく設定するのとかはできそうですね。