絶対に寝坊を許さないための目覚まし時計 via TRTC

# 絶対に寝坊を許さないためのプロトタイピング

おはようございます。unotoviveです。

## 前書き（しょうもないので読み飛ばしてOK）

みなさん。**待ち合わせに遅刻された**ことはありますか？

私はあります。この前うどん食い行くのに2時間待ちました。そして私もかなり遅刻をするタイプの人間です。

せっかくエンジニアをやっているのでこれは技術で解決しましょう。

### 原因究明

まず、何故人は遅刻をするのか。

- 用意に時間がかかった
- 電車を間違えた
- 間に合う気が無かった

色々あると思いますが、一番多いのはやはり

#### 寝坊

ではないでしょうか。

人類は寝坊を防ぐために目覚まし時計という文明の利器を発明しました。しかし一部の進化した人類は目覚まし時計に適合し、NIDONEやKIKOENAI、更には無意識状態での目覚まし時計のHAKAI等というテクニックを用いて再び寝坊をするようになりました。

寝坊常習犯同士ともなると、もう待ち合わせ場所に向かっている時点で、あ、あいつまだ寝とるな。寝坊やんな。と気づくわけですが、この時点で介入をすることで、寝坊の被害を最小限に抑えよう、というのがこの記事の趣旨となります。

## 作るもの

今回作るものは、遠隔で対象者を起こすことができる目覚まし時計です。

機能要求は以下になります。登場人物は起こされる側の「常習犯」と起こすがわの「被害者」2名です。

- 常習犯は、翌朝の起床時刻を設定できる
- 目覚まし時計は、指定の時刻に大音量で鳴り響く
- 常習犯は、起床し鳴り響く目覚ましを止めることができる

ここまでは普通の目覚まし時計です。

- 目覚まし時計は、時刻を設定したタイミングで共有用のURLを発行できる
- 被害者は、現状の目覚ましのステータスを確認できる
- 被害者は、目覚ましが鳴ってから1時間の間、再度目覚ましを鳴らすことができる
- 被害者は、目覚ましが鳴ってから1時間の間、強制的に音声のチャネルを確立できる

この機能により、目覚ましを止めて二度寝、や、ガン無視して寝続けるなどといった愚行を働いた際に、直接「[起きろタコ野郎](https://crd.ndl.go.jp/reference/modules/d3ndlcrdentry/index.php?page=ref_view&id=1000306036)」と催促をすることができます。

~~それでも寝てたら友達辞めましょう。~~

今回はプロトタイピングなので、以下の機能は今後の展望として取っておきます。

- ログインなどのユーザー認証機能（プロトなのでセキュリティに大問題がありますが良しとします）
- 二度寝状況の監視機能（今回はLINEやTwitterの生存確認で手動で行う）
- 部屋の明かりを操作する機能（今後盛り込みたい）
- スマホアプリによる操作（今回はサクッとWebで作成します）

## 技術構成

目覚まし本体は、アプリを終了されるorミュートされることを回避する目的で物理デバイスとします。

まずはプロトタイピングのため、お手軽なRaspberryPiにNode.jsの構成で本体を構成。

操作用のアプリケーションはVue/Nuxtでサクッと作成します。
また、面倒なのでPushは実装せず、firebaseのサブスクリプションでごまかします。
firebaseと音声送信用のTencentCloudは公式のSDKを利用します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/253700/1bf4630e-f24d-0f45-49f7-90e6c3704b64.png)


## 実装

### フロントエンド編

共有される人、常習犯のフロントエンドは本来別れていることが望ましいですが、例の如くプロトなので一緒に作ってしまいましょう。

適当にnuxi initなどでNuxtのプロジェクトを用意します。
```sh
$ nuxi init okirooo
$ cd okirooo
$ npm install
$ npm run dev
```
ページ(ルーティング)の構成は以下のようにします
```
/alarm-setting (常習犯初期ページ)
/alarm (常習犯設定ページ)
/connect/:alerm-uid (被害者ページ（共有される)
```
#### 常習犯初期ページ

簡単なところから作りましょう。

常習犯が自分の持っている目覚ましのIDを登録するページです。やる事は目覚ましが存在するかを確認し、LocalstorageにIDを保持するだけです。

##### 見た目
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/253700/b6300fd6-8d48-877a-afcf-77488068087f.png)

##### 目覚ましの存在確認と保持

```ts
const onSubmit = async () => {
  if (await checkAlarmIdExist(deviceId.value)) {
    localStorage.setItem("deviceId", deviceId.value);
    router.push(`/alarm/${deviceId.value}`);
  } else {
    alert(
      "アラームが登録されていません。お使いのアラームがインターネットに接続していることを確認してください。"
    );
  }
};

const checkAlarmIdExist = async (deviceId: string): Promise<boolean> => {
  try {
    const querySnapshot = await getDoc(doc(db, "devices", deviceId));
    return querySnapshot.exists();
  } catch (error) {
    alert(error);
    return false;
  }
};
```

#### 常習犯設定ページ


常習犯のフロントエンドでは時刻の設定、共有URLの発行ができる必要があります。

##### 見た目

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/253700/c63802c9-9a05-fb0a-6f77-84f873038c39.png)


##### 目覚まし時刻の新規設定
```typescript
const onSubmitNewAlarm = async () => {
  if (new Date(newAlarmDateTime.value) > new Date()) {
    alert("今より後を指定してください");
    return;
  }
  const docRef = await addDoc(collection(db, "alarms"), {
    deviceId: deviceId.value,
    datetime: Timestamp.fromDate(new Date(newAlarmDateTime.value)),
    status: "waiting",
  });
};
```

##### 一覧の表示とURLの作成
```typescript
onMounted(async () => {
  const q = query(
    collection(db, "alarms"),
    where("deviceId", "==", deviceId.value)
  );
  const querySnapshot = await getDocs(q);
  const alarmList = [];
  querySnapshot.forEach((doc) => {
    alarmList.push({ id: doc.id, ...doc.data() });
  });
});
```

#### 共有される人ページ

共有された人のフロントエンドでは、ステータス(設定時刻等)の確認できる機能、特定の時間範囲の中で再度アラームを鳴らす機能、特定の時間範囲の中で音声を送信できる機能が必要です。

##### 見た目
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/253700/68bad646-e71a-0165-5093-6b6c05b931a9.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/253700/3cc70969-2c61-2a59-b7ce-f14524fad523.png)

##### 表示データの取得
```typescript
onMounted(async () => {
  const docRef = doc(db, "alarms", alarmId.value);
  const docSnap = await onSnapshot(docRef, (doc) => {
    if (doc.exists()) {
      alarmData.value = doc.data();
    } else {
      alert("目覚ましのデータに接続できませんでした。");
    }
  });
});
```

##### アラームを鳴らす

アラームはfirebase上の特定のフラグの操作で鳴らせるようにします。

```typescript
const ringAlarm = async () => {
  const docRef = doc(db, "alarms", alarmId.value);
  await updateDoc(docRef, {
    status: "ringRequest",
  });
};
```

##### 音声送信機能

今回のアプリケーションで技術的には恐らく一番難しい部分です。しかしTencentCloudのSDKを利用して比較的簡単に実装することができました。

TencentCloudのダッシュボードからTRTCのアプリケーションを作成し、言われたとおりに選択して進めます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/253700/fdab627a-cadb-a59f-733f-cbf3649ff53c.png)


SDKをインストールします
```
$ npm install trtc-js-sdk
```

TRTCのWebのAPIExampleが[GitHub](https://github.com/LiteAVSDK/TRTC_Web)に公開されています。ありがたいことにReactやVue（2も3も）に対応しており、ここから必要なコードをコピーしてくると簡単に実装できます。

今回は音声の通信のみのため、Utilsなどを転用した後にhundleJoinなどの関数を改変しつつ持ってきます。
Video関連の記述やロガーとの接続などは省いてしまっています。またUI関連の記述もAlertなどのシンプルなものに変更しています。
```typescript

async function handleJoin() {
  if (!store.getInitParamsStates()) {
    alert("paramsNeed");
    return;
  }
  const userSig = store.getUserSig();

  try {
    localClient = TRTC.createClient({
      mode: "rtc",
      sdkAppId: parseInt(store.sdkAppId, 10),
      userId: store.userId,
      userSig,
    });
    console.info(`Client [${store.userId}] created`);
    installEventHandlers();
    await localClient.join({ roomId: parseInt(store.roomId, 10) });
    store.isJoined = true;
    inviteLink.value = store.createShareLink();
    console.info(`Join room [${store.roomId}] success`);
  } catch (error: any) {
    console.error(
      `Join room ${store.roomId} failed, please check your params. Error: ${error.message_}`
    );
  }

  await createLocalStream();
  await handlePublish();
}

async function createLocalStream() {
  try {
    localStream = TRTC.createStream({
      userId: store.userId,
      audio: true,
      video: false,
      microphoneId: store.audioDeviceId,
    });

    await localStream.initialize();
    console.log(`LocalStream [${store.userId}] initialized`);

    localStream
      .play("local")
      .then(() => {
        addLocalControlView();
        console.log(`LocalStream [${store.userId}] playing`);
      })
      .catch((e) => {
        console.log(
          `LocalStream [${store.userId}] failed to play. Error: ${e.message_}`
        );
      });
  } catch (error: any) {
    console.error(`LocalStream failed to initialize. Error: ${error.message_}`);
  }
}

async function handlePublish() {
  if (!store.isJoined) {
    alert({
      message: "call publish()- please join() firstly",
      type: "warning",
    });
    return;
  }
  if (store.isPublished) {
    alert({ message: "duplicate publish() observed", type: "warning" });
    return;
  }

  try {
    await localClient.publish(localStream);
    console.info("LocalStream is published successfully");
    store.isPublished = true;
  } catch (error: any) {
    console.error(`LocalStream is failed to publish. Error: ${error.message_}`);
  }
}
```

DemoではURLを発行していますが、今回はある程度情報が決まっているのでRoomIdだけを渡し、目覚まし側でJoin用のシグネチャを作成します。Publishされたタイミングで以下の関数を発火してfirestoreに通知しましょう。

```typescript
const notifyConnectWaiting = async () => {
  const docRef = doc(db, "alarms", alarmId.value);
  await updateDoc(docRef, {
    status: "connectRequest",
    connectRoomId:  store.roomId
  });
}
```



例のごとくKey関連はハードコーディングしてあるため（本番では絶対にできませんが...）これでボタン押下で音声チャネルが開かれる状態になりました。あとは、開いたチャネルに対してデバイス側から接続を行うだけで機能が完成です。

一つ問題があるとすると、向こう側で最適なUnsubscriptionを設定しておかないと一度繋いだらずっと開きっぱなしでリソースを使いつぶしてしまうことになります。これもそのうち直します。

ここまでで概ねの操作インターフェースは完成です。

### 目覚まし時計編

目覚まし時計は、指定された時刻にアラームを鳴らし、リクエストがあった際に音声を再生できる機能が必要です。

また、流石になってる目覚ましをデバイス単体で止められないのもよく無いので特定の入力で音声を止めるような機能も付けましょう。

~~ラズパイ上のnodeで動くアプリケーションを作成します。~~

当初はこの予定でしたが、Electronなどを介してでないとTencentCloudのSDKをnodeで動かす事が出来なさそうだったため、手抜き感がありますがこちらもブラウザで動作する状態のものを作成して開きます。

##### 共有用URLのためのIDをfirebaseに登録する(初回)&登録されるAlarmのサブスクリプション

起動時に自身のdeviceIdをfirebaseに登録します。作ってから気づいたのですがこのコレクション要らないかもしれない...

```typescript
onMounted(async () => {
  const deviceSnap = await getDoc(doc(db, "devices", deviceId.value));
  if (!deviceSnap.exists()) {
    await setDoc(doc(db, "devices", deviceId.value), {
      created: new Date(),
    });
  }
  const q = query(
    collection(db, "alarms"),
    where("deviceId", "==", deviceId.value)
  );
  const querySnapshot = await getDocs(q);
  const alarmListTmp = [];
  querySnapshot.forEach((doc) => {
    alarmListTmp.push({ id: doc.id, ...doc.data() });
  });
  alarmList.value = alarmListTmp;
});
```

##### 指定の時刻にアラームを鳴らす

有効なアラーム
```typescript
const activeAlarm = computed(() => {
  alarmList.value.filter((alarm) => {
    return alarm.status !== 'archive';
  });
});
```

無効なアラームの削除
```typescript
// update用の関数
const updateStatus = async (status: string, alarmId: string) => {
  const docRef = doc(db, "alarms", alarmId);
  await updateDoc(docRef, {
    status: status,
  });
};
```

```typescript
if (
  alarm.datetime.toDate().getTime() + 1000 * 60 * 60 * 1 >
    new Date().getTime()
) {
    updateStatus("archive", alarm.id);
}
```

##### 鳴らす＆止める

```typescript
const ringAlarm = () => {
  updateStatus("ringing", deviceId.value);
  ringInterval.value = setInterval(() => {
    sound();
  }, 1000);
};
const stopAlarm = () => {
  updateStatus("stopped", deviceId.value);
  ringInterval.value();
};
function sound() {
  const ctx = new AudioContext();
  const osc = ctx.createOscillator();
  osc.type = "square";
  osc.connect(ctx.destination);
  osc.start();
  osc.stop(1);
}
```

##### 音声チャネルを接続する

```typescript
const connectVoiceChannel = async (roomId: string) => {
  try {
    localClient = new Client({
      sdkAppId: sdkAppId.value,
      userSig: userSig.value,
      userId: userId.value,
      roomId,
    });
    const client = localClient.getClient();

    await localClient.join();
    await localClient.publish();
    const localStream = localClient.getLocalStream();

    await nextTick();
    localStream.play("local");
  } catch (error: any) {
    alert({
      message: error.message_,
      type: "error",
    });
  }
};
```

シグネチャの生成にはUtilsに用意されている関数を使用します。が、URLごと共有してしまったほうが早かったかもしれません。

以上でコアな機能は完成です。あとは電源接続時のアプリケーション自動起動などを設定して完成です。

### 終わりに

正直な話TencentCloudは今まで利用した事が無かったのですが、各種SDKやドキュメント、しっかりTypescriptに対応しているサンプルコードなどの用意もあり、それなりに使いやすかったかなと思います。

こんな感じで使用状況の確認なども出来ます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/253700/f5970df9-8897-58cb-60fe-7211e2284cd4.png)


TencentCloudのTRTCのSDKでは、シンプルな音声接続以外にも、様々なエフェクトやBGMの挿入などの機能が用意されています。今後その辺りも利用してもう少し機能を広げていきたいです。

また今回作成したものは最適な技術を使っているか、セキュリティ的にどうか、など様々な問題、改善点がありますが、firebaseなどのBaaSやTencentCloudのSDKを利用することで高速なプロトタイピングを行うことが出来ました。

1からキッチリ作っていてはとても時間がかかったはずなので、個人開発やPoC、MVP機能の開発フェーズなどでは高速プロトタイピングも選択肢として大きくあるなと感じました。

※余計な機能を沢山つけて物騒になってきた目覚まし君
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/253700/fac53e0b-b15a-1b4e-4be8-00b951f0dd38.png)

長々とお読みいただきありがとうございました。