クリスマスイブに画面に向かって全力で応援できるアプリを作った

クリスマスイブは特別な夜だ。

家族と過ごす人もいれば、恋人とイルミネーションでも眺めている人もいるんだろう。

けれども、世間は広い。
巨大なクリスマスツリーを見上げるカップルをわき目に、ひっそりと自宅で自作のガトーショコラを頬張っている僕みたいな人もいる。

世にはこういう人を「クリぼっち」と呼ぶらしい。

chatGPTによると、12月24日に独りで過ごすと、なぜか二倍くらい孤独感が増幅するとのこと(論拠不明)。

周りがにぎやかであればあるほど、比較によって寂しさが身にしみるのかもしれない。

ちなみに私はガトーショコラを炊飯器で焼いてみたのだが、これがなかなか取り出せなくて、結局帰宅したスーツ姿のまま炊飯釜にスプーンを突っ込んで食べる羽目になった。
なかなかシュールな図だったが、誰も見ていないから気にならない。

**気になってはいけない。**

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ガトーショコラ作ったのに炊飯器から取り出せない <a href="https://t.co/ZVqUIANPZV">pic.twitter.com/ZVqUIANPZV</a></p>&mdash; テリスケ(旧こーちゃん)@28週連続LT (@cor_terisuke) <a href="https://twitter.com/cor_terisuke/status/1871499990696394788?ref_src=twsrc%5Etfw">December 24, 2024</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

しかし、このまま寂しさをかみしめていてもしかたがない。
とりあえず「ぼっち　寂しさ　紛らわす方法」で検索すると、大声を出してみるとストレス発散になるとのこと。
横隔膜付近の自律神経がどうたらこうたら…

そうだ、何か思い切り叫べるアプリを作ろう。

そう思いついて、今回は「プリキュア応援アプリ」というものを作った。

別にプリキュア好きかと言われるとそうではない。
先日姪っ子と保護者役で見にいったプリキュアの映画を観覧中、姪っ子がドン引きするほどピンクのサイリウムを振っていた程度だ。
その程度だ。  

## なぜプリキュアなのか

大人になると大声を出すタイミングって、実は意外に少ない。
誰かの家でパーティーでもしているならともかく、私のように炊飯器に向かってガトーショコラをほじくっている人間には、叫ぶ環境も機会もないからだ。

ところが、プリキュア（あるいはヒーローもの）にはピンチと応援が付きもので、観客側が大きな声で

**「頑張れー！！」**

と叫ぶシーンがほぼ必ずある。
つまり、年甲斐もなく合法的にヒーローショーの観客として熱くなれるのだ。  
今回は音声認識で「頑張れ」というワードを検知し、なおかつ一定音量以上の声を出したらゲームクリア、みたいな仕様にして、誰でも手軽に一人ヒーローショーを楽しめるようにした。
立ち上がりとしては、それで十分だろう。  

## コードの全体像

今回はNext.jsのプロジェクトで実装している。ディレクトリ構成はざっくり以下の通り。

![スクリーンショット 2024-12-24 22.46.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3357738/8ed980b6-b771-52d6-2575-afcf3b8b4bde.png)


`utils/audio.ts` で音量を取得するためのロジックをまとめ、`components/GameScreen.tsx` で音声認識とゲーム進行を管理している。  

### AudioContext API で音量検知

声の大きさを計測するには、`AudioContext` と `AnalyserNode` を使う。ブラウザでマイクの音声データを取得し、その波形データや周波数データを使って音量っぽい値を算出するのだが、実際には色々な形がある。その中でも今回の実装はすごく単純で、周波数成分をざっくり平均して擬似的な音量レベルとした。  

```ts
// utils/audio.ts

let audioContext: AudioContext
let analyser: AnalyserNode
let dataArray: Uint8Array

export async function setupAudio() {
  audioContext = new (window.AudioContext)()
  analyser = audioContext.createAnalyser()
  analyser.fftSize = 256
  dataArray = new Uint8Array(analyser.frequencyBinCount)

  try {
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
    const source = audioContext.createMediaStreamSource(stream)
    source.connect(analyser)
  } catch (err) {
    console.error('マイクの取得に失敗:', err)
    throw err
  }
}

export function getDBLevel() {
  analyser.getByteFrequencyData(dataArray)
  const average = dataArray.reduce((sum, value) => sum + value, 0) / dataArray.length
  const db = Math.round((average * 20) / 255)
  return db
}
```
`setupAudio` では `getUserMedia` でマイクへのアクセスを取得し、`audioContext.createMediaStreamSource` でマイク音声をオーディオノードとして生成、そのまま `analyser` に接続している。
`getDBLevel` では周波数データを `getByteFrequencyData` で取得し、単純に平均を取ってから、ざっくり20倍した値を255で割っている。
なぜ20かというと、感覚的にそのくらいの範囲で見やすい数値になるから。
深い意味はあまりない。

これで「現在の音量は○○ dB」と表示させているが、実際には本当のデシベルとはだいぶかけ離れたテキトーな換算である。
そこは気にしない。

自作ヒーローショーだし、気分が大事なので。

### WebSpeech API で「頑張れ」を認識する

ヒーローショーといえば応援の言葉が命。
そこで登場するのが`SpeechRecognition`（または `webkitSpeechRecognition`）を使ったブラウザの音声認識だ。
これは実装するとき少しおまじないが必要で、型を拡張するなどの対応をしないと TypeScript に怒られることがある。
```ts
// components/GameScreen.tsx
declare global {
  interface Window {
    SpeechRecognition: typeof SpeechRecognition;
    webkitSpeechRecognition: typeof SpeechRecognition;
  }
}
```
このように宣言しないと「そんなものは存在しないぞ」とTypeScriptにどつかれてしまう。

で、あとは `SpeechRecognition` オブジェクトを生成して、音声認識を開始するだけだ。

```ts
recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)()
recognition.continuous = true
recognition.interimResults = true
recognition.lang = 'ja-JP'

recognition.onresult = (event) => {
  // ここで声の認識結果を取得する
}
```
`onresult` で返ってくる `event` の `results` には、認識されたテキストが入っている。`isFinal` が `true` のものが最終的な結果だ。
まだ途中の段階を `interimResults` として取得できることもあるが、ゲーム性を考えると最終結果だけ拾っても十分だろう。

### 音声認識と音量認識を組み合わせる
さて、本アプリのゲームルールは「大きな声で頑張れと言う」というもの。
なので以下の条件を満たせばクリアとしている。

1.`SpeechRecognition` で「頑張れ」が検出される
（正確には `transcript.includes('頑張れ')` で判定している）
2.`AudioContext` で取得した音量が一定以上
（ここでは周囲に配慮して3db以上）

それを `GameScreen.tsx` の中ではこんなふうに書いている。
```ts
if (hasPhrase && hasVolume) {
  console.log('クリア条件達成！', { transcript, maxVolume: maxVolumeRef.current })
  onEnd(maxVolumeRef.current)
  setIsGameRunning(false)
  clearTimeout(timer)
} else if (hasPhrase) {
  setMessage('もう少し大きい声で！')
  audio3.play();
} else if (hasVolume) {
  setMessage('頑張れと言ってね！')
  audio2.play();
} else {
  setMessage('')
}
```
`hasPhrase` は「頑張れ」が検知されたかどうか、`hasVolume` は `maxVolumeRef.current >= 3db` かどうかを意味する。
両方揃えばゲームクリアだし、片方だけなら注意を促す音声を再生する。

全部ダメなら無言で画面を見つめてるということで、40秒後に勝手にゲームオーバーになる。

<blockquote class="twitter-tweet" data-media-max-width="560"><p lang="ja" dir="ltr">プリキュアアプリほぼ完成 <a href="https://t.co/Z7TV8ICJRr">pic.twitter.com/Z7TV8ICJRr</a></p>&mdash; テリスケ(旧こーちゃん)@28週連続LT (@cor_terisuke) <a href="https://twitter.com/cor_terisuke/status/1871554744914960754?ref_src=twsrc%5Etfw">December 24, 2024</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

### 苦労した点：最大音量をしばらく維持したい
ここで一筋縄ではいかなかったのが、音量検知と音声認識のタイミングがちぐはぐになること。
実際に試してみると、「頑張れ」と叫んでも、音声認識が先に進んでテキストは取得できたものの、その瞬間に音量検知の値が下がっていることがあった。

要するに、「ああ、頑張れは聞こえた。けどもう声のピークが過ぎてるんだわ」とアプリに言われているようなものだ。

とても悔しい。

そこで `maxVolumeRef` を用意して、しばらくの間最大値を保持する仕組みにした。
大きな声を出してからほんの数ミリ秒でも認識できればいい、というスタンスで妥協している。

ここはもう少し工夫して「音声認識と音量ピークの時間差をマッチさせる」みたいな高度なロジックを組んでもいいが、クリスマスイブの夜にそんな気力は湧かなかった。

妥協は大事だ。

### 実際のフロー
1. `audio1（前置き.wav）`を再生して「ゲーム説明が流れる」
2. 再生終了後、SpeechRecognition を開始してタイマー（40秒）を開始
3. その間にユーザが「頑張れー！！」と叫ぶ
4. 叫んだ音声が音声認識されれば `lastDetectedPhrase` にテキストが入り、音量が高ければ `maxVolume` が更新される
5. 「頑張れ」というワードが入り、かつ `3dB` 以上の音量を検知した瞬間にクリア判定
6. もし40秒以内にクリアできなかったら `LoseScreen` が表示され、ゲームオーバー。再挑戦するか選べる
 
こんな流れのアプリが完成した。
もし壁が薄いアパートなら、隣人を驚かせないようにほどほどの音量で試してほしい。

## クリぼっちなあなたへのプレゼント

こんなアプリでも、寂しさをちょっとはまぎらわせることができるかもしれない。
炊飯器からうまく取り出せないガトーショコラを食べつつ、自宅でプリキュアを応援してみるという謎の行為。
なぜかちょっと楽しい。

ソースコードは GitHub で公開しているので、興味があれば手元で動かしてみてほしい。

https://github.com/terisuke/pretty_cure

また、実際のアプリはVercel にデプロイした。なんとなくブラウザで試してみたい人は覗いてみればいい。

https://pretty-cure.vercel.app/

さらに、12月26日に開催されるLTイベントで、このアプリを使って
「会場の観覧者も合わせてゲームをクリアする」
というLTを披露する予定だ。
自分でも何を言っているのかわからないが、うまくいけば今年の締めにふさわしいLTができるだろう。
興味があればぜひおいでいただきたい。

https://connpass.com/event/335957/

「クリぼっち」だろうと複数人だろうと、結局クリスマスイブは「やりたいことをやる」のがいちばんだと思う。
大きな声で「頑張れ！」と叫んでストレス発散して、年明けに向けてもう少し前向きな気持ちになれたら幸いだ。

**良いクリスマスを！**