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するのは微妙では…?という感情が芽生えたのでなんとかしたい

github.com

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 さんに聞いたところ、以下で議論はされているらしい)

github.com

と困っていたところ、上司のid:ma2sakaさんからなんか infer というのを使えばできるらしいと聞いたのでとりあえず調べてやってみた、という次第である。

infer

TypeScriptには Conditional Typeというのがあり、ある型 TK を継承している場合とそうでない場合に型を場合分けできるらしい。

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の知識

スクリプト

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で表示するようにした)

追記: 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

美顔器 (@motemen) / Twitter

inspired by

airreader.hatenablog.com

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で雑に実装した。

github.com

まず以下のような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の使い方を今更理解した初心者

経緯

mysticdoll.hatenablog.jp

これで使ってるtc2vvというアプリケーションが、Twitch側のチャットのWebSocketのハンドラのスレッドが死んでいるっぽいがプロセス自体は生きている半死状態だったのを直したかった。

github.com

結論から言うと、 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

github.com

通常 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、 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

まとめ

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

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