自宅kubernetesクラスタでTwitchのチャット読み上げツールを自作した話

概要

4年と9ヵ月働いたヤフーを退職して暇になったので、新しい盆栽を初めたという話。

システム構成

用意したもの

kubernetes、 CoreDNS+etcd

kubeadmを使って雑に構築、ingress-nginx-controller, flannel, metallb, external-dnsあたりを採用しています。以下を参考にして構築しました。

nnstt1.hatenablog.com

wiki.archlinux.jp

以下雑記

  • ingress-nginx-controllerについては、別でrtmpコンポーネントが動いていたりするので、tcp-services周りの設定を入れていますが、それ以外はほぼバニラです。
  • external-dnsでは *.mystic.doll を良い感じに解決するように設定しています。

TwitchとOAuthで認証するやつ

Rustで書きました。axum で雑に書きました。

今まではTwitchのAccessTokenを取るのに以下のシェルスクリプトでやっていたんですが、こちらをkubernetes上に移し、ついでにkubernetesのSecretとして保存するようにしました。

#/bin/bash

echo access "https://id.twitch.tv/oauth2/authorize?client_id=${TWITCH_CLIENT_ID}&redirect_uri=http://localhost:8000/twark/&response_type=code&scope=chat:read+chat:edit+channel:read:redemptions"

CODE=$(nc -lp8000 | head -1 | cut -d'?' -f2 | cut -d'=' -f2 | cut -d'&' -f1)
export TWITCH_ACCESS_TOKEN=$(curl -XPOST "https://id.twitch.tv/oauth2/token?client_id=${TWITCH_CLIENT_ID}&client_secret=${TWITCH_CLIENT_SECRET}&code=${CODE}&grant_type=authorization_code&redirect_uri=http://localhost:8000/twark/" | jq -r '.access_token')

kubernetesAPIを叩くクライアントのcrateもあるにはあるが、ドキュメントを読んで面倒になったので、自分でリクエスト投げることにしました。

github.com

kubernetesAPIでSecretがなければ登録、あれば更新、というのが見つからなかったので自前で実装した結果可読性が最惡になっています。

voicevoxのあれこれ

解説が面倒なのでmanifestsを全部まとめて下記に置いておきます。 大体以下のことをしています。

  • kubernetes上にpod配置してServiceでクラスタ内、ingressクラスタ外からアクセスできるようにしてます。
  • Default設定ではAccess-Control-Allow-OriginとかCORS周りの設定が厳しめなので、これを緩くしています。
    • 適当に試していたときユーザー設定のところに置いた状態で初回起動したとき設定が反映されなくてキレたのでdefaultを置き換えています。
    • そもそも初回起動時に設定をを反映させる手段がundocumentedだったのでコード読んでたんですがpythonのappdirsのデフォルトに配置されるっぽいがうまくいかなかった
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: voicevox
  labels:
    app: voicevox
spec:
  replicas: 1
  selector:
    matchLabels:
      app: voicevox
  template:
    metadata:
      labels:
        app: voicevox
    spec:
      containers:
      - name: voicevox
        image: voicevox/voicevox_engine:cpu-ubuntu20.04-latest
        ports:
        - containerPort: 50021
        volumeMounts:
        - name: default-setting
          mountPath: /opt/voicevox_engine/default_setting.yml
          subPath: default_setting.yml
      volumes:
      - name: default-setting
        configMap:
          name: voicevox-config
          items:
          - key: "default_setting.yml"
            path: "default_setting.yml"
---
apiVersion: v1
kind: Service
metadata:
  name: voicevox
  annotations:
    external-dns.alpha.kubernetes.io/hostname: voicevox.mystic.doll.
spec:
  selector:
    app: voicevox
  ports:
  - name: voicevox
    protocol: TCP
    port: 80
    targetPort: 50021
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: voicevox
spec:
  ingressClassName: nginx
  rules:
  - host: voicevox.mystic.doll
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: voicevox
            port:
              number: 80
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: voicevox-config
data:
  default_setting.yml: |
    allow_origin: "*"
    cors_policy_mode: all

Twitchのチャットを拾ってきてvoicevoxのクエリに変更するWebSocketサーバ

やりたいこととしては WebSocket<TwitchChat> -> WebSocket<VoiceVoxQuery> というマッピングを実現するサーバです。

通常読み上げ自体をやりたければ、フロントエンド側でチャットに接続→チャットの内容をvoicevoxに投げる、というだけで良いです。 ですが、これを実装しようと思うとフロントエンド側にAccessTokenを渡す必要があり、Secretsに渡した意味が皆無になってしまうので、こういった実装になりました。

tokio-tungstenite でChatに接続、Message::Text を受け取る度にvoicevoxにリクエストして、今自分が喋ってるWebSokectサーバに繋いできてるpeerにvoicevoxの音声合成クエリをbroadcastしています。

詳しくはリポジトリのコードを見てください。

github.com

雑にWebAudio APIで読み上げするフロントエンド

雑にnginxで一式ホストしています。Manifestとしては以下。

真面目にアプリケーションを書く必要が0なので、雑にFetchしてきてblobをWebAudio APIに投げるようなコードを書いています。

---
apiVersion: v1
kind: ConfigMap
metadata:
  namespace: twitch-tools
  name: tts-html
data:
  index.html: |
    <html>
      <body>
        <script>
          const connectWebSocket = async () => {
            const socket = new WebSocket("ws://tc2vv.mystic.doll");
            socket.addEventListener("message", async (e) => {
              const data = JSON.parse(e.data);
              if (data.kana.length < 40) {
                const ctx = new AudioContext();
                const res = await fetch("http://voicevox.mystic.doll/synthesis?speaker=1", {method:"POST", body: e.data, headers: {"Content-Type": "application/json"}});
                const arrayBuffer = await res.arrayBuffer();
                const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
                const source = ctx.createBufferSource();

                source.buffer = audioBuffer;
                source.connect(ctx.destination);
                source.start();
              }
            });
            socket.addEventListener("close", () => {
              connectWebSocket();
            });
          };

          connectWebSocket();
        </script>
      </body>
    </html>
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: tts
  namespace: twitch-tools
spec:
  replicas: 2
  selector:
    matchLabels:
      app: tts
  template:
    metadata:
      labels:
        app:
          tts
    spec:
      containers:
      - name: nginx
        image: nginx
        ports:
        - containerPort: 80
        volumeMounts:
        - name: index-html
          mountPath: /usr/share/nginx/html/index.html
          subPath: index.html
      volumes:
      - name: index-html
        configMap:
          name: tts-html
          items:
          - key: "index.html"
            path: "index.html"
---
apiVersion: v1
kind: Service
metadata:
  name: tts
  namespace: twitch-tools
spec:
  selector:
    app: tts
  ports:
  - name: tts
    protocol: TCP
    port: 80
    targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: tts
  namespace: twitch-tools
spec:
  ingressClassName: nginx
  rules:
  - host: tts.mystic.doll
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: tts
            port:
              number: 80

まとめ

普通に棒読みちゃんとか使ったほうがいいです。

あとうちみたいな場末の配信には別に読み上げいらないです。