音声でしか投稿できないSNS

この記事は[クソアプリ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/kuso-app)の  
シリーズ1の14日目の記事です。

## つぶやきたい。。でも文字打つのがめんどくさい。。

TwitterなどのSNS、ちょっとつぶやくときにいいけど、  
キーボードを打つのがめんどくさいときも。。

「ひとりごとをそのままツイートできれば。。」

**そんなめんどくさがり屋のためのSNSを作りました！**

## つくったもの

音声でしか投稿できないSNS
「VoitterX」

https://voitterx.web.app/

トップにはルームの一覧があるので、  
好きな部屋に入って、

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/82559e74-2b82-1f58-846c-299f9e40fce1.png" width="300"/>

マイクの権限を許可して話すと、  
自動で投稿してくれます！

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/bf5b3c5a-6267-4dd4-f718-15564fe5e547.gif" width="300"/>

これでつぶやいたことがそのまま投稿に!!

※最初に匿名認証をしているのですこし待ちが長いです。。
※Chromeじゃないと動かないかも。。

## 使った技術

構成としてはこんな感じ。

![スクリーンショット 2023-12-12 10.34.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/068a671b-41aa-36c1-52c8-4c484d2e1b64.png)

- アプリ部分
  - [Firebase Hosting](https://firebase.google.com/docs/hosting?hl=ja)
  - [Flutter](https://flutter.dev/) Web/CanvasKit
  - 音声認識: [speech_to_text | Flutter Package](https://pub.dev/packages/speech_to_text)
  - トースト通知: [toastification | Flutter Package](https://pub.dev/packages/toastification)
- サーバ部分
  - [Cloud Functions for Firebase(第2世代)](https://firebase.google.com/docs/functions/beta/get-started?hl=ja)
  - [unjs/nitro](https://nitro.unjs.io/)
  - OG画像生成: [satori](https://github.com/vercel/satori) + [sharp](https://github.com/lovell/sharp) + [satori-html](https://github.com/natemoo-re/satori-html)
- データ部分
  - [Firebase Firestore](https://firebase.google.com/docs/firestore?hl=ja)

Flutter Webを試してみたかったのと、  
ルームごとのOGP画像を動的に生成したかったので、  
Cloud Functions(2nd)も使うような構成に。

### 音声認識

音声認識パッケージには、Flutterのspeech_to_textを利用。  
Webの場合の中身は、Web Speech APIを利用しているよう。

- [speech_to_text | Flutter Package](https://pub.dev/packages/speech_to_text)
- [ウェブ音声 API - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/Web_Speech_API)

#### speech_to_textの使い方

speech_to_textの使い方はこんな感じ。

```dart
import 'package:speech_to_text/speech_to_text.dart';

final SpeechToText client = SpeechToText();
String status = "";
String resultWords = "";

// 初期化
await client.initialize(onStatus: (status) {
  // ステータスの変化を受け取って保存
  status = status;
});

// 音声認識の開始
await client.listen(
  onResult: (result) {
    // 音声認識の結果を受け取って保存
    resultWords = result.recognizedWords;
  },
  partialResults: true, // 途中経過の結果も受け取るように
  localeId: "ja-JP", // ロケールは日本語に設定
);

// 音声認識の停止
await client.stop();
```

#### 音声認識を継続する

ルームに入ったときは、音声認識を保ってほしい。  
speech_to_textの`partialResults: true`をセットすると、

```dart
// 音声認識の開始
await client.listen(
  partialResults: true, // 途中経過の結果も受け取るように
);
```

Web Speech APIの`continuous = true`をセットしてくれる。

```javascript
const recognition = new SpeechRecognition();
recognition.continuous = true;
```

また、speech_to_textのステータス(`status`)の値と、
対応するWeb Speech APIのイベントはこんな感じ。

```dart
// 初期化
await client.initialize(onStatus: (status) {
  // ステータスの変化を受け取って保存
  if (status == "listening") {
    // 入力音声の取得中
    // - SpeechRecognition.onStart
    // - SpeechRecognition.onSpeechStart
  } else if (status == "notListening") {
    // 入力音声の取得停止中
    // - SpeechRecognition.onEnd
  } else if (status == "done") {
    // 音声認識が完了(結果あり)
    // - SpeechRecognition.onEnd、かつ、結果があるとき
  } else if (status == "doneNoResult") {
    // 音声認識が完了(結果なし)
    // - SpeechRecognition.onEnd、かつ、結果がないとき
  }
});
```

`done`や`doneNoResult`のあとは音声認識が完了し、音声入力が停止するので、  
ステータスに応じて再度`client.listen()`を呼び出したり、  
`Timer.periodic()`を使ったりして、継続するようにしている。

#### 自動で投稿する

自動投稿部分はこんな感じ。

- `onResult()`で結果を受け取り続け、  
- 0.5秒間、結果の変更がなければ自動投稿する


```dart
// 音声認識の開始
await client.listen(
  onResult: (result) {
    // 音声認識の結果を受け取って保存
    resultWords = result.recognizedWords;
    
    // Timerをつかって遅延実行する
    _createTimer(resultWords);
  },
  partialResults: true, // 途中経過の結果も受け取るように
  localeId: "ja-JP", // ロケールは日本語に設定
);

// 0.5秒間、音声認識の結果を受け取らなければ、
// 自動で投稿する処理
Timer? _timer = null;

Timer _createTimer(String content) {
  // すでにTimerがあれば、キャンセル
  _timer?.cancel();
  
  // 0.5秒後に音声認識の結果を投稿する
  _timer = Timer(const Duration(milliseconds: 500), () async {
    try {
      if (content == null || content.isEmpty) return;

      // TODO: firestoreに追加する処理
    } finally {
      // 結果(SpeechRecognitionResult)が残り続けるので
      // 一旦停止して、再度listenし直すことで結果を空にする
      await stopListen();
    }
  });
}
```

## おわりに

これで手が離せなくても、つぶやけちゃいますね♪
よかったら、ぜひ遊んでみてください〜(\*´ω｀\*)

https://voitterx.web.app/

---

P.S. 

ちょっと長くなってきたので、OGP周りの話は、    
[Flutter Advent Calendar 2023](https://qiita.com/advent-calendar/2023/flutter)の16日目の記事で書きます！