詩と創作・思索のひろば

ドキドキギュンギュンダイアリーです!!!

Fork me on GitHub

3Dアバターに話を聞いてもらう

近年、世間ではリモートでスピーチをする機会が増加している。人前で喋ったことがあると経験があるのではないかと思うけど、テレビ会議ごしに話をすると目線が合わない、そもそも顔が見えないなどで反応がものすごく薄い。だんだんと慣れてきた感もあるけどやっぱり喋りづらい。技術による人間の疎外、これは仕方がないことですよ。

そうであれば解決するのも技術だ、というわけで作ってみたのがこちら(マイクの権限が必要です)。ブラウザの中から誰かがこちらを見てくれていて、話にうなずいてくれる。ただそれだけ。だけど、多数に向けたスピーチの際にうなずいてくれる人間の貴重さを知っている人には、このありがたさに共感してくれるのではないだろうか。

Vnodroid

pixiv/three-vrm を使って3Dアバターヒューマノイドをブラウザ上に表示し、あたかもそこで自分の話を聞いているかのように存在させている。呼吸とまばたきを加えるだけでそれっぽさが増すのでおもしろい。

問題はうなずきをどう嘘っぽくないタイミングで挟み込むかだけど、Web Speech API の SpeechRecognition を使ってみた。これを使うとリアルタイムにマイク音声の書き起こしをしてくれるのだけど(ブラウザすげーな!)、その内容が確定されたタイミングが話の一区切りと見てうなずくようにしてみた。まあ悪くはないんじゃないかと思う。

動きは基本モーションみたいなものが配布されてるんじゃないかと思って調べてみたけどわからず、結局 JS で書いた。まばたきは 3D グラフィックス初心者が Web で VRoid をアニメーションさせてみた - Qiita のものを拝借した。

以下にデモ動画をあげているので見てもらえると感じがつかめると思う。

デモ兼紹介動画

続きを読む

Bubble Tea でリッチなターミナルアプリケーションを作る #Go

近年、普段の作業をマウスでやりたくない気持ちが高まっている(デスク周りが散らかってきたせいだという説が有力です)。メールは結局ターミナルでメールを読むことにしたため問題なく過ごせているが、その他のタスクをキーボードだけでやるには、ターミナル動くアプリケーションを作れる必要がある。それもリッチなやつだ。見た目は派手な方がいい。

この記事は Kyoto.go remote #32 LT会 で発表した 入門 Bubble Tea の増補版です。

Bubble Tea とは

GitHub - charmbracelet/bubbletea: A powerful little TUI framework 🏗

Bubble Tea とは、Go でリッチなターミナルアプリケーション(TUI)を作るためのフレームワーク。Charm というプロジェクトの一部のようで、ホームページを見てもらったら分かるとおり異様にお洒落である。これはやる気が出ますね。Bubble Tea とは英語でタピオカティーのことらしい。なるほど。

README によれば、Bubble Tea は Elm Architecture に基づいたフレームワークである、とのこと。以下、Elm Architecture を解説しつつ bubbletea を使っていく。

The Elm Architecture

まず Elm という言語がある。Haskell ライクな関数型で、JavaScript にトランスパイルされるような言語だ。Elm がウェブでインタラクティブな UI を実現するために採用しているのが Elm Architecture。

この Elm Architecture は Redux の prior art として名前をあげられているので、このあたりを触ったことがあれば簡単に理解できると思う。

Elm Architecture では Model がアプリケーションの唯一のステートとなり、関数 View によって Model から UI が生成される(Elm なら HTML だし、Bubble Tea なら文字列)。ユーザなどモデル外部からの入力は Msg として表され、関数 Update で処理されて新しい Model が生成される。流れが一方向になっているので御しやすいというわけ。

tea.Model

さて、このアーキテクチャに則って、bubbletea で Go における TUI 実装を見ていく。まずは tea.Model を見てみる(bubbletea は tea という名前でパッケージを定義している)。

type Model interface {
    Init() Cmd
    Update(Msg) (Model, Cmd)
    View() string
}

Cmd はあとで見ることにして、先の説明どおりなことが分かるだろう。

  • Update() は新しい Model を返す。
  • View() は文字列を返す。これがそのままターミナルに表示される!

では React でも見るようなカウンタを作ってみよう。スペースキーを押したら数を1ずつ増やしていくようなアプリケーションにしてみる。

ソースコードは https://github.com/motemen/example-go-bubbletea/tree/main/01-counter

type model struct {
    count int
}

func (model) Init() tea.Cmd {
    return nil
}

func (m model) View() string {
    return fmt.Sprintf("count: %v", m.count)
}

どれもそのまま書き下したような形で、とくに解説もいらないだろう。アプリケーションのロジックが集中するのは Update になる。

まず書くなら、今回はこういう感じだ。

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case " ":
            m.count = m.count + 1
            return m, nil // 更新したモデルを返す → View() が描画される
        }
    }

    return m, nil
}

tea.Msg の実体は interface{} で、つまり何でもいい。アプリケーションそれぞれに特有の Msg を実装することになるが、事前に定義されたものもあり、tea.KeyMsg はその一つ。ユーザからのキー入力はこれで表される。

スペースキーが押されたときに、count を 1 増やした新しい model を返している。その model に対して View() が呼ばれ、結果の文字列がユーザのターミナルに描画される、という簡単な流れだ。完成!

これでも動くのだけど、このままだとこのプログラムは終了できない。Ctrl+C でさえも bubbletea が奪ってしまうからだ。そこでこういうコードも追加する。

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    ...
        switch msg.String() {
        case "q", "ctrl+c":
            return m, tea.Quit
        ...
        }
     ...
}

Ctrl+C されたときに、tea.Quit という値も返している。この Cmd を返すと bubbletea がプログラムを終了してくれるというわけだ。これで本当に完成。

スペースキーを連打してます

tea.Cmd

tea.Cmd が登場した。Cmd とは Elm にも登場する概念で、外界への(副作用を伴う)命令を表現している。そしてその命令が終わったら、同じく Msg を戻してくることになっている。

bubbletea においては単に tea.Msg を返す関数であり、さらに言うと goroutine の中で実行される。

type Cmd func() Msg

では先ほどのカウンタをメモリ上ではなく外に置くことにしてみよう。おあつらえむきに CountAPI というサービスがあったので、これを利用する。ウェブのアクセスカウンターみたいく、GET したらカウントを返してくれる API がある。おあつらえむきすぎる……。

ソースコードは https://github.com/motemen/example-go-bubbletea/tree/main/02-counter-api

重要なコードは以下。

type countLoadedMsg struct {
    count int
}

func (m model) hitCounter() tea.Msg {
    count, err := m.api.Hit() // ここで HTTP API を叩く……
    ...
    return countLoadedMsg{count: count} // モデルに渡すための Msg
}

countLoadedMsg という独自の Msg を定義する。API から結果が返ってきたらこれにくるんでモデルに渡してやるためのものだ。型をみれば分かるとおり、m.hitCounter が Cmd になる。

これを利用する Update は以下のようになる。

func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case " ":
            m.loading = true
            return m, m.hitCounter // ここで Cmd を返し…
        }
    case countLoadedMsg: // Cmd の結果を受けとる
        m.count = msg.count
        m.loading = false
        return m, nil
    }

    return m, nil
}

counter += 1 する代わりに Cmd を返し、結果が帰ってきたらモデルを更新するというだけ。Update の仕事はシンプルなままだ。

その他のコードも少しずつ変更する。

func (m model) Init() tea.Cmd {
    return m.hitCounter // プログラム開始時に実行したい Cmd
}

func (m model) View() string {
    if m.loading {
        return "..."
    } else {
        return fmt.Sprintf("count: %v", m.count)
    }
}

起動時にも HTTP API を叩いてカウントを取得したいので、先ほどはスルーした Init() で Cmd を返してやる。あとロード中はそれっぽい表示にしてあげたり。だんだんダイナミックな感じになってまいりました。

少し読み込みを待ってるのがおわかりでしょうか

bubbles でコンポーネントを使う

いやぜんぜんリッチな感じじゃないんだが……。そうですよね。bubbletea のいいところは bubbles というコンポーネント集があるところだ。

GitHub - charmbracelet/bubbles: TUI components for Bubble Tea 🍡

README 見てたらアガるよね! ではロード中にスピナーを回してみることにする。

bubbles のコンポーネントは bubbletea のモデルとして提供されるので、以下のように組み込んでいく。

type model struct {
    ... 
    spinner spinner.Model
}

func (m model) View() string {
    if m.loading {
        return m.spinner.View() // '-' とか '/' とか '|' とかになる
    }
    ...
}

子モデルとして spinner.Model を持たせ、ロード中は ... の代わりに回転する棒を表示させるように View を書き換えた。

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmd tea.Cmd
    m.spinner, cmd = m.spinner.Update(msg) // ここで Tick を受け取っていたらスピナーの表示が変わる
    ...
}

func (m model) Init() tea.Cmd {
    return tea.Batch(
        m.hitCounter,
        m.spinner.Tick, // スピナーの回転をはじめる
    )
}

Update では、子モデルにも Msg を渡す。具体的には spinner.Tick という Msg が重要で、これが数百msごとにスピナーを回転させるための Msg になっている。

以上のソースコードは https://github.com/motemen/example-go-bubbletea/tree/main/03-counter-api-spinner

線がくるくる回っている!!!

もっとリッチに!

bubbles には他にもいろいろなコンポーネントがあるが、最後にリストを使った例をあげて終わりにする。試しに Scrapbox のページを一覧するようなコードを書いてみる。

コードの全体は https://github.com/motemen/example-go-bubbletea/tree/main/04-list-api

モデルはこんな感じだ。

type model struct {
    projectName string
    list        list.Model
}

func initModel(projectName string) model {
    m := model{
        projectName: projectName,
        list:        list.New(nil, list.NewDefaultDelegate(), 1, 1),
    }
    ...
    return m
}

list.NewDefaultDelegate() はリストの要素を描画してくれるオブジェクトを返す。とりあえずこれを使っていればいい感じになる(逆にこれを使わないと、けっこう苦労して描画することになる)。

これでリストの要素をいい感じに描画してもらうために、リストに表示したい struct は list.DefaultItem を実装する。

type scrapboxPage struct {
    Title_ string `json:"title"`
    ID     string `json:"id"`
}

// Description implements list.DefaultItem
func (p scrapboxPage) Description() string {
    return p.ID
}

// Title implements list.DefaultItem
func (p scrapboxPage) Title() string {
    return p.Title_
}

// FilterValue implements list.Item
func (p scrapboxPage) FilterValue() string {
    return p.Title_
}

API を叩いたり結果を Msg に詰める処理は省略。リストを画面いっぱいに表示するため、WindowSizeMsg を受け取って、list.SetSize() する。メソッドには Cmd を返すものとそうでないものとあるので、気にして使わないと思ったように動かないことがあるので注意。

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmd tea.Cmd
    m.list, cmd = m.list.Update(msg)

    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.list.SetSize(msg.Width, msg.Height)
    ...
    }
    
    return m, cmd
}

あとはボイラープレートみたいなもんで、これで完成。簡単でしたね!

ほかにもいろいろ

ほかにも bubbletea のコンパニオンとして、ターミナルに文字列を描画したり色をいい感じに取り扱ったりするライブラリ群があるのだけど紹介しきれないのでここまで。自分も使いこなせてない部分があるし……。それではよい TUI ライフを。

The Go gopher is designed by Renee French, licensed under CC BY 3.0

新しくリポジトリを作るまでもないコードは motemen/go-nuts に置いている #Go

OAuth2 のよくあるフローを何回も書きたくない #Go - 詩と創作・思索のひろば

golang.org/x みたいに、独立させるまでもないけど再利用したいパッケージを go-nuts レポジトリにまとめるのいいな。真似しようかな(名前もかわいい)

2022/06/06 12:04
golang.org/x みたいに、独立させるまでもないけど再利用したいパッケージを go-nuts レポジトリにまとめるのいいな。真似しようかな(名前もかわいい) - ono_matope のブックマーク / はてなブックマーク

そうなんです! Go は中央的なパッケージリポジトリがないので、新しいパッケージを作る心理的コストは他の言語に較べてかなり低い。とはいえ GitHub にリポジトリを増やすのも、CI 周りだとか、バージョニングであるとか、脳内ネームスペースの管理だとかで負担がゼロなわけではない。そういうわけで、共通化したくなったコードを気軽に置く場所として motemen/go-nuts というリポジトリを作り、そこにパッケージを放り込むことにしている。

このやり方は go4.org を真似している。どこで見たんだったかは忘れてしまったけど、たぶんこういった微妙なスキマを埋めるライブラリを探していたときに見つけたんだと思う。

README には

  • single repo. go4 is a single repo. That means things can be changed and rearranged globally atomically with ease and confidence.
  • no backwards compatibility. go4 makes no backwards compatibility promises. If you want to use go4, vendor it. And next time you update your vendor tree, update to the latest API if things in go4 changed. The plan is to eventually provide tools to make this easier.
  • forward progress because we have no backwards compatibility, it's always okay to change things to make things better. That also means the bar for contributions is lower. We don't have to get the API 100% correct in the first commit.

とあって著名なエンジニアがこれをやってるので勇気づけられる。

Go には

A little copying is better than a little dependency.

という格言がある ように、少しのコピペなら許容する文化なので普段もコピペしているが、何度も繰り返してコピペに我慢できなくなったときに切り出す。「あのコードどこに書いたっけ……」となる前に、一種のスニペット集に登録しているようなものとも言える。あと仕事で使いたくなったときとかね。

何度かコピペを繰り返したころには、どんなインタフェースに抽象化したらいいか分かっていることも多い。逆に早い段階でやっちゃうと微妙であとから使わないこともある。

go-nuts という命名は、golang-nuts という雑多なトピックを取り扱う ML から取ったんじゃなかかったかなー。最初は Go と nuts の関係がわかってなかったんだけど、Gophers love nuts. とのことでなるほど可愛らしいね、と思った覚え。

はてなで一緒に働きませんか?