概要
4年と9ヵ月働いたヤフーを退職して暇になったので、新しい盆栽を初めたという話。
システム構成
用意したもの
- kubernetes (1 master, 2 nodes)
- CoreDNS + etcd (物理サーバ, docker-composeで動いてる)
- TwitchとOAuthで認証するためのアプリケーション https://github.com/MysticDoll/twitch-oauth-docker/tree/k8s
- voicevoxのDeployment, Service, Ingress一式
- Twitchのチャットを拾ってきてvoicevoxのクエリに変更するWebSocketサーバ https://github.com/MysticDoll/tc2vv
- 雑にWebAudio APIで読み上げするフロントエンド(nginx pod生やして対応)
kubernetes、 CoreDNS+etcd
kubeadmを使って雑に構築、ingress-nginx-controller, flannel, metallb, external-dnsあたりを採用しています。以下を参考にして構築しました。
以下雑記
- 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')
kubernetesのAPIを叩くクライアントのcrateもあるにはあるが、ドキュメントを読んで面倒になったので、自分でリクエスト投げることにしました。
kubernetesのAPIで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しています。
詳しくはリポジトリのコードを見てください。
雑に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
まとめ
普通に棒読みちゃんとか使ったほうがいいです。
あとうちみたいな場末の配信には別に読み上げいらないです。