TypeScriptのGenerics Typeで一部の型引数のみを指定する
近況
自分はちゃんと勉強せず雰囲気でググりながらTypeScriptを使っているのに、人にTypeScriptを教えている状況。
Generic Type
TypeScriptにおいてはある型T
に対し、別の型K
を使って型を定義したい場合以下のように記述できる。
interface T<K> = { P1: K }; type TNumber = T<number>; // { P1: number }
このとき型引数にはデフォルトとなる型を指定できる
interface T<K = number> { P1: K };
課題
今回問題になったのが、Generic Typeにおいて複数の型引数を持ち、かつそれぞれの型引数にデフォルト型が設定されている型に対し、一部の型のみ指定するような型を定義したいというところ。
具体的には、express.jsにおいて、req.body.prop
にアクセスする際、no-unsafe-member-access
でESLintから怒られてしまう、これを回避したいという課題があった。
これ自体は DefinitlyTypedで定義されているように、req
の型に Request<core.ParamDictionary, any, { prop: SomeType }>
というように指定すれば回避できるが、これだけのために使いもしない core.ParamDictionary
をimportするのは微妙では…?という感情が芽生えたのでなんとかしたい
app.get("/", (req: Request<core.ParamDictionary, any, { prop: SomeType }>, res: Response) => { const a: SomeType = req.body.prop; });
(こういう感じで定義はできる)
Rustでは以下のように指定をスキップする型引数に _
を指定することで実現できるが、TypeScriptにはそういう機能は今はないらしい。
let a: SomeType<_, _, String> = ...;
(id:mizdra さんに聞いたところ、以下で議論はされているらしい)
と困っていたところ、上司のid:ma2sakaさんからなんか infer
というのを使えばできるらしいと聞いたのでとりあえず調べてやってみた、という次第である。
infer
TypeScriptには Conditional Typeというのがあり、ある型 T
が K
を継承している場合とそうでない場合に型を場合分けできるらしい。
type A<T> = T extends K ? B : C;
この際、KがGeneric Typeである場合には以下のようにinfer
を使うことで、型を推論して取り出すことが可能らしい。
type A<T> = T extends K<infer F, infer S> ? B<F, S> : C;
これを利用すると、express.Request<P, ResBody, ReqBody, ReqQuery, Locals>
という型の ReqBody
だけ指定したい場合以下の型を定義することで可能となる。
type CustomRequest<T> = Request extends Request<infer P, infer ResBody, infer ReqBody, infer ReqQuery, infer Locals> ? Request<P, ResBody, T, ReqQuery, Locals> : never;
Request
という型は何も指定しなければデフォルト型が設定されるので infer
で推論してもらえば各デフォルト型を取り出して使える、これを使ってデフォルト引数の指定を実質的に飛ばすことができる。
一応 ReqBody
以降の型引数も記述したが、 ReqBody
以降の型引数は省略できるので、実用上は以下みたいになるだろうか
type CustomRequest<ReqBody> = Reqeust extends Request<infer P, infer ResBody> ? Request<P, ResBody, ReqBody> : never;
正直型引数の指定を飛ばしたいだけなのにここまで必要なのはどうかとも思うが…そのあたりは今後の機能拡充に期待したい。
仮想端末にCTOを表示する
経緯
前職ではチームの先輩や隣のチームのリーダーのアイコンを色つきAAにして.bashrcで表示するようにしていたが、はてな入社後は特にそういったことをしておらず、まずいと思ったため勢いで実装した。
必要なもの
- 色つきAAにして可視性が高いアイコンをしている同僚
- 偶然にもCTOのアイコンがtodefyされていたので助かった
- todefyされていると非常に楽だが、されてなくてもうまくやれば良い感じのAAにはできる
- 最惡は自分で人のアイコンをtodefyしてください
- ANSI escape code/SGR(Select Graphic Rendition) parameterの知識
- 偶然にも英Wikipediaに情報が充実していた
- ANSI escape code - Wikipedia
スクリプト
motemenさんを表示したい人はこれでできます。たぶん。
echo -e '\x1b[48;2;195;213;227m \x1b[m\x1b[48;2;195;213;227m \x1b[m\x1b[48;2;252;255;246m \x1b[m\x1b[48;2;252;255;246m \x1b[m\x1b[48;2;19;58;137m \x1b[m\x1b[48;2;19;58;137m \x1b[m\x1b[48;2;157;167;185m \x1b[m\x1b[48;2;157;167;185m \x1b[m\n\x1b[48;2;129;82;38m \x1b[m\x1b[48;2;129;82;38m \x1b[m\x1b[48;2;255;224;211m \x1b[m\x1b[48;2;255;224;211m \x1b[m\x1b[48;2;248;199;182m \x1b[m\x1b[48;2;248;199;182m \x1b[m\x1b[48;2;218;46;32m \x1b[m\x1b[48;2;218;46;32m \x1b[m\n\x1b[48;2;164;172;193m \x1b[m\x1b[48;2;164;172;193m \x1b[m\x1b[48;2;254;196;184m \x1b[m\x1b[48;2;254;196;184m \x1b[m\x1b[48;2;255;179;162m \x1b[m\x1b[48;2;255;179;162m \x1b[m\x1b[48;2;225;107;93m \x1b[m\x1b[48;2;225;107;93m \x1b[m\n\x1b[48;2;174;179;183m \x1b[m\x1b[48;2;174;179;183m \x1b[m\x1b[48;2;55;62;68m \x1b[m\x1b[48;2;55;62;68m \x1b[m\x1b[48;2;83;32;13m \x1b[m\x1b[48;2;83;32;13m \x1b[m\x1b[48;2;89;90;95m \x1b[m\x1b[48;2;89;90;95m \x1b[m'
良く見ると SGRの指定 (空白1つ) デフォルトに戻すSGR としているが、SGRの指定
(空白2つ) デフォルトに戻すSGR とした方が短くて済む。このあたり片手間でやってたのでそんなに頭が働いていない
ANSI escape code/SGR
平たく言えば、仮想端末上でカラーリングや動的に変化する情報の表示(htop
とかみたいな)などに使われている仕組み。
今回使うSGRの機能としては文字の背景色の切り替えとなる。
これは、\x1b[48;2;<r>;<g>;<b>m
を仮想端末上でエンコードして出力すると、その後の文字の出力時に文字背景色が指定した r
g
b
の値に対応する色となる。という機能である。
これはもう1度SGRの機能で表示設定を変更しない限り続く、これを解除するには \x1b[m
を仮想端末上でエンコードして出力する必要がある。*1
今回の場合偶然にも以下のリポジトリにCTOのアイコンの各色のカラーコードが記載されていたのでNode.jsで雑に生成することができた。改行と幅の調整だけ手作業で入力した。 github.com
const m = [ "c3d5e3", "fcfff6", "133a89", "9da7b9", "815226", "ffe0d3", "f8c7b6", "da2e20", "a4acc1", "fec4b8", "ffb3a2", "e16b5d", "aeb3b7", "373e44", "53200d", "595a5f" ]; m.map(c => c.match(/\w{2}/g).map(c => Number.parseInt(c, 16))).map(([r,g,b]) => `\x1b[48;2;${r};${g};${b}m \x1b[m`).map(c => `${c}${c}`).join('');
簡単なやり方
画像をカラーつきAAに変換するツールでHTMLを生成→CLIブラウザ等で表示→その出力をリダイレクトしてテキストファイルに保存。
そうするとSGRも含めて良い感じのテキストが得られるので、後は頑張れば良い感じになる。この場合 \x1b[38;2;<r>;<g>;<b>m
での文字色の変更も含まれていたりするので先ほどの手作業よりも表現力の高いAAが仮想端末上で表示できる。
さすがにtodefyアイコンよりは手間がかかるが…
前職ではそういう感じで頑張っていた。
まとめ
現職同僚各位で「俺のアイコンを仮想端末に表示してくれ」という人がいたら連絡ください。
追記: PS1で表示したい人向け(大きいサイズ)
export PS1="\\n\\e[48;2;195;213;227m \\e[m\\e[48;2;252;255;246m \\e[m\\e[48;2;19;58;137m \\e[m\\e[48;2;157;167;185m \\e[m\\n\\e[48;2;195;213;227m \\e[m\\e[48;2;252;255;246m \\e[m\\e[48;2;19;58;137m \\e[m\\e[48;2;157;167;185m \\e[m\\n\\e[48;2;195;213;227m \\e[m\\e[48;2;252;255;246m \\e[m\\e[48;2;19;58;137m \\e[m\\e[48;2;157;167;185m \\e[m\\n\\e[48;2;195;213;227m \\e[m\\e[48;2;252;255;246m \\e[m\\e[48;2;19;58;137m \\e[m\\e[48;2;157;167;185m \\e[m\\n\\e[48;2;129;82;38m \\e[m\\e[48;2;255;224;211m \\e[m\\e[48;2;248;199;182m \\e[m\\e[48;2;218;46;32m \\e[m\\n\\e[48;2;129;82;38m \\e[m\\e[48;2;255;224;211m \\e[m\\e[48;2;248;199;182m \\e[m\\e[48;2;218;46;32m \\e[m\\n\\e[48;2;129;82;38m \\e[m\\e[48;2;255;224;211m \\e[m\\e[48;2;248;199;182m \\e[m\\e[48;2;218;46;32m \\e[m\\n\\e[48;2;129;82;38m \\e[m\\e[48;2;255;224;211m \\e[m\\e[48;2;248;199;182m \\e[m\\e[48;2;218;46;32m \\e[m\\n\\e[48;2;164;172;193m \\e[m\\e[48;2;254;196;184m \\e[m\\e[48;2;255;179;162m \\e[m\\e[48;2;225;107;93m \\e[m\\n\\e[48;2;164;172;193m \\e[m\\e[48;2;254;196;184m \\e[m\\e[48;2;255;179;162m \\e[m\\e[48;2;225;107;93m \\e[m\\n\\e[48;2;164;172;193m \\e[m\\e[48;2;254;196;184m \\e[m\\e[48;2;255;179;162m \\e[m\\e[48;2;225;107;93m \\e[m\\n\\e[48;2;164;172;193m \\e[m\\e[48;2;254;196;184m \\e[m\\e[48;2;255;179;162m \\e[m\\e[48;2;225;107;93m \\e[m\\n\\e[48;2;174;179;183m \\e[m\\e[48;2;55;62;68m \\e[m\\e[48;2;83;32;13m \\e[m\\e[48;2;89;90;95m \\e[m\\n\\e[48;2;174;179;183m \\e[m\\e[48;2;55;62;68m \\e[m\\e[48;2;83;32;13m \\e[m\\e[48;2;89;90;95m \\e[m\\n\\e[48;2;174;179;183m \\e[m\\e[48;2;55;62;68m \\e[m\\e[48;2;83;32;13m \\e[m\\e[48;2;89;90;95m \\e[m\\n\\e[48;2;174;179;183m \\e[m\\e[48;2;55;62;68m \\e[m\\e[48;2;83;32;13m \\e[m\\e[48;2;89;90;95m \\e[m \\n\\n:\\w \\$ "
*1:これは文字表示の設定をデフォルトに戻すSGRの指定
motemen15パズル
about motemen
inspired by
15パズルの実装
https://ja.wikipedia.org/wiki/15%25E3%2583%2591%25E3%2582%25BA%25E3%2583%25ABja.wikipedia.org
15パズルの機能を実装するにあたり、考えるべき要素はサイズ16の配列と入れ替え操作の2つのみである。
サイズ16の配列
これは盤面そのものに対応する。
0を空のパネルとして以下の配列があるとする
[0, 1 , 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
これを盤面として表現する場合4要素ずつに区切り、縦に並べてやれば良い
[ [ 0, 1, 2, 3], [ 4, 5, 6, 7], [ 8, 9, 10, 11], [12, 13, 14, 15] ]
制約付き入れ替え操作
入れ替え操作は単に選択したインデックス n
に対し、空のパネルの位置と入れ替える操作を考えれば良い。
ただし、15パズルには、空のパネルと隣接するパネルのみ移動ができる、という物理的制約がある。 そのためこの制約を定義してやる必要がある。
隣接判定
空のパネルのインデックスをe
とし、あるインデックス n
が隣接しているかどうかを判定するためには以下の式が利用できる
abs(e-n) == 1 || abs(e - n) == 4
式の前半はパネルが横方向に隣接しているかの判定、後半はパネルが縦方向に隣接しているかの判定である。
(追記) これだけでは空パネルが端にある場合、次の行の最初の要素と入れ替えられてしまうので、次の判定が追加で必要となる。
(e % 4 == 0 && e - n !== 1) || (e % 4 == 3 && n - e !== 1)
つまり最終的な判定式は以下となる。
((e % 4 == 0 && e - n != 1) || (e % 4 == 3 && n - e != 1)) && abs(e-n) ==1 || abs(e-n) == 4
実物
https://mysticdoll.com/motemen15puzzle
実際に動いている様子はここで確認できる。
実際の実装
React.jsで雑に実装した。
まず以下のようなmotemenを表すデータを定義しておく
// motemen.js export const white = "fcfff6"; export const motemen = [ "c3d5e3", "fcfff6", "133a89", "9da7b9", "815226", "ffe0d3", "f8c7b6", "da2e20", "a4acc1", "fec4b8", "ffb3a2", "e16b5d", "aeb3b7", "373e44", "53200d", "595a5f" ];
そして実際に15パズルそのものを表現するReact Componentは以下のような形となる。
/// Board.jsx import React from 'react'; import { white, motemen } from './motemen' export default class Board extends React.Component { constructor(props) { super(props) this.state = { motemenArray: this.props.motemenRandomized }; } sliceMotemen() { return [ this.state.motemenArray.slice(0, 4), this.state.motemenArray.slice(4, 8), this.state.motemenArray.slice(8, 12), this.state.motemenArray.slice(12, 16), ]; } swap(n) { const w = this.whiteIndex(); if (!((w % 4 === 3 && n - w === 1) || (w % 4 === 0 && w - n === 1))) { const sub = Math.abs(w - n); if (sub === 1 || sub === 4) { const motemenArray = this.state.motemenArray.slice(); motemenArray[w] = this.state.motemenArray[n]; motemenArray[n] = this.state.motemenArray[w]; this.setState({ motemenArray }); if(JSON.stringify(motemen) === JSON.stringify(motemenArray)) { alert('You know motemen.'); } } } } whiteIndex() { return this.state.motemenArray.findIndex(c => c === white) } render() { return ( this.sliceMotemen().map((m, r) => { return ( <p style={{ lineHeight: "0px", margin: "0px" }} key={r}> { m.map((color, index)=> { const style = { height: "128px", width: "128px", background: `#${color}`, content: " ", display: "inline-block" }; const onClick = () => this.swap(index + r * 4); return ( <span key={color} style={style} onClick={onClick}/> ) }) } </p> ) }) ) } }
あとはランダムにシャッフルした配列でBoardを初期化してやれば完成となる。
return ( <div className="App"> <main> <Board motemenRandomized={motemenRandomized}/> </main> </div> );
Rustのtokio::select! macroの使い方を今更理解した初心者
経緯
これで使ってるtc2vvというアプリケーションが、Twitch側のチャットのWebSocketのハンドラのスレッドが死んでいるっぽいがプロセス自体は生きている半死状態だったのを直したかった。
結論から言うと、 tokio::join!
macroを使っていたが、これは指定した Future
(正確には async expression) が全て終了するまで待つという処理をするらしく、チャットのハンドラのThreadとWebSocketをホストするサーバのハンドラのスレッドの両方を待っていたのでちゃんと死んでくれなかった。今回はこれをちゃんと殺すために tokio::select!
を使った。
なんで殺したいか
一言で言えばkubernetes上で動いているから、になる。
kubernetesがPodの死活監視をして勝手にreplica数を調整してくれるが、対象のPodのプロセスが半死状態だとそれが叶わない。なのでしっかりと死んでもらう必要があるわけである。
本来は ReadinessProbe や LivenessProbe のようなProbeを設定すべきではあるのだが、WebSocketの死活状態のProbeをどうやってとるのかすぐに思いつかなかったのと、別にステートレスなものではあるのでしっかり死んでもらって再起動してもらった方が健全な気がしたのでしっかり殺すことにした。
tokio::select!
自分は JavaScriptのPromiseの方で例えると理解しやすい気がしたのだが、tokio::join!
は どちらかというと Promise.all
に近く、tokio::select!
は Promise.race
に近いのかなと思った。
というかドキュメントをちゃんと読んでいなかったということが普通に判明した。
tokio::join!
Waits on multiple concurrent branches, returning when all branches complete.
→ 全部の並列処理が終わるまで待つよ
tokio::select!
Waits on multiple concurrent branches, returning when the first branch completes, cancelling the remaining branches.
→ 平行処理の内最初のものが終わったら他をキャンセルするよ。
読もうね。
今後
とりあえずこれでチャットかWebSocketサーバのどちらかのThreadが予期せず死んだ場合にちゃんとプロセス毎死んでくれるようになったので、kubernetesがAuto healingしてくれるだろうと信じている。
一番厄介なのは実は死んでいなくてWebSocket接続が生きているパターンで、そうだった場合はチャットのハンドラをまた弄らないといけなさそうな気がする…
そういう感じだったらもう通知用のchannel生やしてそこから親Threadからプロセスを殺すことになりそうかな…
ConfigMap/Secretを監視してリソースを再起動してくれる stakater/Reloader を自宅kubernetesに導入した
stakater/Reloader
通常 kubernetesにおいてはConfigMapやSecretが更新された際、それに紐付いたリソース(deployment/daemonset/statefulset等)はそれを検知して再起動するという機能はない。
これを提供するのが、 stakater/Reloader である。
Install
マニフェストをそのままapplyすることで導入できる。
kubectl apply -f https://raw.githubusercontent.com/stakater/Reloader/master/deployments/kubernetes/reloader.yaml
この他にもHelm ChartやKustomizeでのインストールも可能らしい、詳しくはリポジトリのREADMEを参照してほしい。
Usage
指定したConfigMap/Secretの変更を検知してローリングアップデートを実行する
<configmap|secret>.realoader.stakater.com/reload: "<comma-separated-names>"
という形式のannotationをDeployment等に追加するだけで良い。
関連するConfigMap/Secretの変更を検知してローリングアップデートを実行する
また、 reloader.stakater.com/auto: "true"
を設定しておけば、そのリソースに紐付いたConfigMap/Secretの更新時に自動で関係するpodをローリングアップデートしてくれるらしい。
この時の対象となるConfigMap/Secretの探索をannotationで指定したものに限定したい場合は、 reloader.stakater.com/search: "true"
を設定することで実現できる。
この際ローリングアップデートを発火するConfigMap/Secretには reloader.stakater.com/match: "true"
のannotationを指定しておく必要がある。
自宅内運用
自宅では、基本的にはローリングアップデートをトリガーしたいConfigMap/Secretを指定する形でannotationを設定する形で採用した。
これは、意図しないタイミングでのローリングアップデートの実行が怖いのと、ConfigMap/Secretの手動更新時には既に手オペが発生しているので自動更新する必要もないだろうという算段がある。
特に、今回必要となった要因は、OAuthの古いtokenがexpireした際にSecretを更新する方法としてOAuthの砲台からAccess TokenをSecretに保存するpodがいるので、こいつのSecret保存時に手オペするのが面倒という理由だけなので、必要以上に影響範囲を増やさないで運用しようという結論である。
所感
kubernetes内に自身(k8s)内部のConfigMap/Secretを変更しうるサービスが存在しうる場合には非常に助かるツールだとは感じた。
一方でとりあえず入れておいたら便利!という形で導入するにはオーバーパワーな感じもする。
kubernetes内でConfigMap/Secretを変更するようなシステムを運用していて、かつその変更に追従して更新したいリソースがある各位は試してみてはいかがだろうか。(いるのか?)
自宅kubernetesクラスタでTwitchのチャット読み上げツールを自作した話
概要
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
まとめ
普通に棒読みちゃんとか使ったほうがいいです。
あとうちみたいな場末の配信には別に読み上げいらないです。