From d0f53d0722ac521dd085c42747da7e0d014ad689 Mon Sep 17 00:00:00 2001 From: ayn2op Date: Sun, 11 Oct 2026 16:39:22 -0400 Subject: [PATCH] feat: implement voice support Closes #166 --- .github/workflows/ci.yml | 10 +- README.md | 23 +- docs/config.md | 52 +++ go.mod | 8 +- go.sum | 28 +- internal/config/config.go | 16 +- internal/config/keybinds.go | 11 + internal/config/theme.go | 22 +- internal/ui/chat/guildstree/keymap.go | 20 +- internal/ui/chat/guildstree/model.go | 231 ++++++++++++- internal/ui/chat/guildstree/msg.go | 14 +- internal/ui/chat/keymap.go | 1 + internal/ui/chat/messageslist/model.go | 5 - internal/ui/chat/messageslist/msg.go | 26 +- internal/ui/chat/model.go | 7 +- internal/ui/chat/state.go | 2 + internal/ui/dialog.go | 28 ++ internal/ui/login/msg.go | 6 +- internal/ui/modal.go | 18 - internal/ui/root/model.go | 30 +- internal/ui/root/msg.go | 14 +- internal/voice/session.go | 440 +++++++++++++++++++++++++ internal/voice/session_no_voice.go | 27 ++ internal/voice/speaker.go | 120 +++++++ internal/voice/voice.go | 11 + 25 files changed, 1073 insertions(+), 97 deletions(-) create mode 100644 internal/ui/dialog.go delete mode 100644 internal/ui/modal.go create mode 100644 internal/voice/session.go create mode 100644 internal/voice/session_no_voice.go create mode 100644 internal/voice/speaker.go create mode 100644 internal/voice/voice.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5669d66..efceb48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,13 +36,19 @@ jobs: if: ${{ matrix.os != 'windows-11-arm' }} run: | go test -race -v ./... - go test -race -v -tags no_spoof_tls_fingerprint ./... + go test -race -v -tags no_spoof_tls_fingerprint,no_voice ./... # -race is not supported on windows/arm64. - name: Test if: ${{ matrix.os == 'windows-11-arm' }} run: | go test -v ./... - go test -v -tags no_spoof_tls_fingerprint ./... + go test -v -tags no_spoof_tls_fingerprint,no_voice ./... + + # Without voice, no C compiler is needed. + - name: Build without voice + run: go build -tags no_voice -o ${{ runner.temp }}/discordo . + env: + CGO_ENABLED: 0 - name: Build run: go build -trimpath -ldflags=-s . diff --git a/README.md b/README.md index 648b701..d786092 100644 --- a/README.md +++ b/README.md @@ -28,15 +28,22 @@ scoop install discordo ### Building from source -```bash +Requires [Go](https://go.dev/dl). Voice support requires a C compiler, and `libpulse` or `libasound` on Linux. + +```sh git clone https://github.com/ayn2op/discordo cd discordo go build . ``` +#### Tags + +- `no_voice` to build without voice support. +- `no_spoof_tls_fingerprint` to build without TLS fingerprint spoofing. + ## Usage -### Password (UI, recommended) +### Password (UI) 1. Run the `discordo` executable with no arguments. @@ -72,8 +79,20 @@ The configuration file allows you to configure and customize the behavior, keybi - Darwin: `$HOME/Library/Application Support/discordo/config.toml` - Windows: `%AppData%/discordo/config.toml` +Use `-config-path` to change the path. + Discordo uses the default configuration if a configuration file is not found in the aforementioned path; however, the default configuration file is not written to the path. [The configuration reference can be found here](./docs/config.md). +## Logs + +The log file is replaced on each run. + +- Unix: `$XDG_CACHE_HOME/discordo/logs.txt` or `$HOME/.cache/discordo/logs.txt` +- Darwin: `$HOME/Library/Caches/discordo/logs.txt` +- Windows: `%LocalAppData%/discordo/logs.txt` + +Use `-log-path` to change the path, and `-log-level` (`debug`, `info`, `warn`, or `error`) to set the verbosity of what is logged. + ## License Copyright (C) 2025-present ayn2op diff --git a/docs/config.md b/docs/config.md index cf1d757..d8757a8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -406,6 +406,14 @@ Maximum height. 0 makes the list as tall as needed. Default: `0` +# voice + +## voice.sensitivity + +How loud the microphone must be for what is said to be sent, in dB below the loudest it can be. + +Default: `-40.0` + # icons ## icons.guild_category @@ -448,6 +456,16 @@ Default: `"≡ "` Default: `"s-"` +## icons.voice_muted + +Shown after the members of a voice channel who are muted or deafened. + +Default: `" (muted)"` + +## icons.voice_deafened + +Default: `" (deafened)"` + # keybinds Global shortcuts. A keybind is a key such as "ctrl+k", or a list of keys. @@ -468,6 +486,22 @@ Default: `"alt+m"` Default: `"ctrl+k"` +## keybinds.toggle_mute + +Start or stop sending what the microphone captures to the voice channel joined. + +Default: `"alt+v"` + +## keybinds.toggle_deafen + +Stop or start playing what is said in the voice channel joined. Stopping also mutes. + +Default: `"alt+d"` + +## keybinds.leave_voice + +Default: `"alt+l"` + ## keybinds.toggle_help Hide/show keybind help. @@ -616,6 +650,12 @@ Default: `"K"` Default: `"J"` +### keybinds.guilds_tree.toggle_voice + +Join the highlighted voice channel to listen, or leave it. + +Default: `"v"` + ## keybinds.members_tree Only while focusing on the members tree. @@ -948,6 +988,18 @@ Default: `{ foreground = "red" }` Default: `{ foreground = "gray" }` +### theme.guilds_tree.speaking_style + +The style of whoever is speaking in the voice channel joined. + +Default: `{ foreground = "green" }` + +### theme.guilds_tree.voice_limit_style + +The style of how full a voice channel that has a user limit is. + +Default: `{ foreground = "dodgerblue" }` + ## theme.members_tree The status styles are those of the dot before each member. diff --git a/go.mod b/go.mod index 92c6dad..d9b73c5 100644 --- a/go.mod +++ b/go.mod @@ -13,12 +13,13 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/alecthomas/chroma/v2 v2.27.0 github.com/andybalholm/brotli v1.2.6 - github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c + github.com/ayn2op/arikawa/v3 v3.0.0-20261011215609-c5c459e53baa github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e github.com/ayn2op/tview v0.0.0-20261011173501-8bd5978952e4 github.com/bogdanfinn/fhttp v0.6.9 github.com/bogdanfinn/tls-client v1.16.0 github.com/gdamore/tcell/v3 v3.6.0 + github.com/gen2brain/malgo v0.11.26 github.com/gorilla/websocket v1.5.3 github.com/klauspost/compress v1.20.1 github.com/mattn/go-shellwords v1.0.16 @@ -27,6 +28,8 @@ require ( github.com/sahilm/fuzzy v0.1.3 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 + github.com/thomas-vilte/dave-go v0.5.1 + github.com/tphakala/go-opus v1.1.0 github.com/yuin/goldmark v1.8.6 github.com/zalando/go-keyring v0.2.8 golang.design/x/clipboard v0.11.0 @@ -45,6 +48,7 @@ require ( github.com/cloudflare/circl v1.6.5 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/dchest/jsmin v1.0.0 // indirect + github.com/disgoorg/godave v0.3.0 // indirect github.com/dlclark/regexp2/v2 v2.8.4 // indirect github.com/ebitengine/purego v0.11.1 // indirect github.com/gdamore/encoding v1.0.1 // indirect @@ -55,6 +59,8 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/randall77/makefat v0.0.0-20260406194835-1b91746796b7 // indirect github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect + github.com/thomas-vilte/mls-go v1.6.0 // indirect + github.com/tphakala/simd v1.11.0 // indirect github.com/twmb/murmur3 v1.2.1 // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect golang.design/x/x11 v0.2.0 // indirect diff --git a/go.sum b/go.sum index f71802f..36efd34 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI= github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c h1:Qkl3W5RXzUQUR/PyYMMKmUHlmAksxaXRFigaTscoxtw= -github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c/go.mod h1:SHqcr+EmZKeNtVKZybHJn4HNz+ufA1XbeKNSTigBCEE= +github.com/ayn2op/arikawa/v3 v3.0.0-20261011215609-c5c459e53baa h1:uBfUPNrKAwrKweO+vzZSaI2mawNhRZ3QFZuH7JKenq4= +github.com/ayn2op/arikawa/v3 v3.0.0-20261011215609-c5c459e53baa/go.mod h1:YARcqZmk7hDjXB+lexlXX4wDFHULafbGnF0gsD0tuNw= github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e h1:QAei/IVEyZV7GcpkpcFUh37+QRqwrt57z7OT3ciHrhI= github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e/go.mod h1:RsvYiMzi4Si1F7vmml5NCL2DaKIZXkNUQC7QlGTMtCw= github.com/ayn2op/tview v0.0.0-20261011173501-8bd5978952e4 h1:ViehR1nggJ7caDHIj9bp2Z8VqS00wur0xrOo1E6YNGs= @@ -38,10 +38,10 @@ github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQ github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dchest/jsmin v1.0.0 h1:Y2hWXmGZiRxtl+VcTksyucgTlYxnhPzTozCwx9gy9zI= github.com/dchest/jsmin v1.0.0/go.mod h1:AVBIund7Mr7lKXT70hKT2YgL3XEXUaUk5iw9DZ8b0Uc= +github.com/disgoorg/godave v0.3.0 h1:37F3ZuiMd8/EXeoCtTeE8iP2eT2nWsfEa4/ITwoKfe4= +github.com/disgoorg/godave v0.3.0/go.mod h1:OreAC3hpabr39bMVA+jwOVDq1EUPXH5A0XUBiZaDI1Y= github.com/dlclark/regexp2/v2 v2.8.4 h1:yfDDXTn4oxyLDC10/ieOBNDCCdIVWS9WbT6ddVXsPKI= github.com/dlclark/regexp2/v2 v2.8.4/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0= @@ -50,6 +50,8 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uh github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell/v3 v3.6.0 h1:jW20qS6Tm38KVDqLHq9KWSvyjNl00FbhbD8rIAnHBws= github.com/gdamore/tcell/v3 v3.6.0/go.mod h1:CBZxkl2T+XxEyuBfOh4zaahqm27dngvhfBfCCZxJsgc= +github.com/gen2brain/malgo v0.11.26 h1:k5WcPIKw1bbJAbPqrvNPt7nehPLoaPNcOFde2+eruiM= +github.com/gen2brain/malgo v0.11.26/go.mod h1:xLVG3ROA33Bzol1quF3e4ehqcFuqh8QK4B8T6LQUs/M= github.com/godbus/dbus/v5 v5.2.3 h1:IQNSXULh6Nc4ZH8ETJ5Mdsj8loUFCWSBnsjmJkyjUB8= github.com/godbus/dbus/v5 v5.2.3/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= @@ -70,8 +72,6 @@ github.com/mattn/go-shellwords v1.0.16 h1:RRxAaRzU1YbzOSCj9NJqg2/VIbSWv0dnPoD3Ew github.com/mattn/go-shellwords v1.0.16/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/ncruces/zenity v0.10.15 h1:UaHekkxwt7QjoZmMWtzhqSEtQT97/Cux6FDBWwfyuqk= github.com/ncruces/zenity v0.10.15/go.mod h1:45d81tt+vF/UjBjlWE/vvE0oD0hWuHUQld4vPbA4VxE= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/randall77/makefat v0.0.0-20260406194835-1b91746796b7 h1:m1yKMZwDSXkT5o2MKhd6ihdzb2dYb6eElNE04xjOSEY= @@ -86,10 +86,18 @@ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EE github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc= github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng= +github.com/thomas-vilte/dave-go v0.5.1 h1:sc0roOie5Ayn+3eKOUjry9kbBPDvFQCF8t+gy/EOrW4= +github.com/thomas-vilte/dave-go v0.5.1/go.mod h1:h0UvVsCDECUe6W6dlSHEQTbx3TT13pGmHGngG0GXg1o= +github.com/thomas-vilte/mls-go v1.6.0 h1:HGxrRe0kRfEAXu7MtTemNzqGmC/758rfGkarhYGPOYg= +github.com/thomas-vilte/mls-go v1.6.0/go.mod h1:Cxl407ecAW94wwYLOycpQUp0UyfBk6dNmvs1hkw2yLg= +github.com/tphakala/go-opus v1.1.0 h1:SbXQjwI/ZYy6wPXd0fafLTurkUbAhBpaYnvXYtn8uIo= +github.com/tphakala/go-opus v1.1.0/go.mod h1:57hQHipntY2+xK7sycF4CYVSXDLW3V+AiX77iOJlojQ= +github.com/tphakala/simd v1.11.0 h1:3rtUU/6mO76R4rq3hQiEX+DC4yKc/twIGU62bkmIAYA= +github.com/tphakala/simd v1.11.0/go.mod h1:eO53GkWg40mRbr2tzs0Xb1IMZQ5U8cMR+Aa1RlXUinE= github.com/twmb/murmur3 v1.2.1 h1:TJzZGygOc1VJ7jiBpy8m7KAAzm67zy1jvkUxxEjJp+g= github.com/twmb/murmur3 v1.2.1/go.mod h1:LVqs/avIy0flspVtmBd4VSbogDepFl6Wkj0i0ToccHE= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= @@ -103,6 +111,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= golang.design/x/clipboard v0.11.0 h1:kEKLOBEffOeIBNI7tyKnQzgSkKWX8XTHooVde1C8O7Q= @@ -160,5 +170,3 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index 8d993a6..c64325d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -95,6 +95,11 @@ type ( Receive bool `toml:"receive"` } + Voice struct { + // How loud the microphone must be for what is said to be sent, in dB below the loudest it can be. + Sensitivity float64 `toml:"sensitivity"` + } + Icons struct { GuildCategory string `toml:"guild_category"` GuildText string `toml:"guild_text"` @@ -108,6 +113,10 @@ type ( GuildAnnouncement string `toml:"guild_announcement"` GuildForum string `toml:"guild_forum"` GuildStore string `toml:"guild_store"` + + // Shown after the members of a voice channel who are muted or deafened. + VoiceMuted string `toml:"voice_muted"` + VoiceDeafened string `toml:"voice_deafened"` } PickerConfig struct { @@ -206,6 +215,7 @@ type ( // Layout and behavior of the widgets. UI UIConfig `toml:"ui"` + Voice Voice `toml:"voice"` Icons Icons `toml:"icons"` // Global shortcuts. A keybind is a key such as "ctrl+k", or a list of keys. @@ -254,7 +264,8 @@ func Default() Config { MembersTree: pane, Composer: ComposerConfig{MaxHeight: 10}, - UI: defaultUI(), + UI: defaultUI(), + Voice: Voice{Sensitivity: -40}, Icons: Icons{ GuildText: "#", GuildVoice: "♪ ", @@ -267,6 +278,9 @@ func Default() Config { GuildAnnouncement: "a-", GuildForum: "≡ ", GuildStore: "s-", + + VoiceMuted: " (muted)", + VoiceDeafened: " (deafened)", }, Keybinds: defaultKeybinds(), diff --git a/internal/config/keybinds.go b/internal/config/keybinds.go index 59294ae..c6e4c2d 100644 --- a/internal/config/keybinds.go +++ b/internal/config/keybinds.go @@ -96,6 +96,8 @@ type GuildsTreeKeybinds struct { // Highlight the previous or next unread guild or channel, wrapping around. SelectPreviousUnread Keybind `toml:"select_previous_unread"` SelectNextUnread Keybind `toml:"select_next_unread"` + // Join the highlighted voice channel to listen, or leave it. + ToggleVoice Keybind `toml:"toggle_voice"` } type MessagesListKeybinds struct { @@ -161,6 +163,11 @@ type Keybinds struct { // Hide/show the members tree. ToggleMembersTree Keybind `toml:"toggle_members_tree"` ToggleChannelsPicker Keybind `toml:"toggle_channels_picker"` + // Start or stop sending what the microphone captures to the voice channel joined. + ToggleMute Keybind `toml:"toggle_mute"` + // Stop or start playing what is said in the voice channel joined. Stopping also mutes. + ToggleDeafen Keybind `toml:"toggle_deafen"` + LeaveVoice Keybind `toml:"leave_voice"` // Hide/show keybind help. ToggleHelp Keybind `toml:"toggle_help"` // Switch between compact and full keybind help. @@ -231,6 +238,9 @@ func defaultKeybinds() Keybinds { ToggleGuildsTree: key("alt+g", "toggle guilds"), ToggleMembersTree: key("alt+m", "toggle members"), ToggleChannelsPicker: key("ctrl+k", "channels picker"), + ToggleMute: key("alt+v", "toggle mute"), + ToggleDeafen: key("alt+d", "toggle deafen"), + LeaveVoice: key("alt+l", "leave voice"), ToggleHelp: key("alt+.", "toggle help"), ToggleFullHelp: key("ctrl+.", "full help"), Suspend: key("ctrl+z", "suspend"), @@ -252,6 +262,7 @@ func defaultKeybinds() Keybinds { TreeKeybinds: tree, SelectPreviousUnread: key("K", "prev unread"), SelectNextUnread: key("J", "next unread"), + ToggleVoice: key("v", "join voice"), }, MembersTree: tree, MessagesList: MessagesListKeybinds{ diff --git a/internal/config/theme.go b/internal/config/theme.go index 11b7eda..b2f4d0e 100644 --- a/internal/config/theme.go +++ b/internal/config/theme.go @@ -205,7 +205,7 @@ type ( Footer StyleThemeConfig `toml:"footer"` Border StyleThemeConfig `toml:"border"` // The status styles are those of direct-message recipients. - GuildsTree TreeThemeConfig `toml:"guilds_tree"` + GuildsTree GuildsTreeThemeConfig `toml:"guilds_tree"` // The status styles are those of the dot before each member. MembersTree TreeThemeConfig `toml:"members_tree"` ScrollBar ScrollBarThemeConfig `toml:"scroll_bar"` @@ -216,6 +216,14 @@ type ( } ) +type GuildsTreeThemeConfig struct { + TreeThemeConfig + // The style of whoever is speaking in the voice channel joined. + SpeakingStyle StyleWrapper `toml:"speaking_style"` + // The style of how full a voice channel that has a user limit is. + VoiceLimitStyle StyleWrapper `toml:"voice_limit_style"` +} + type TreeThemeConfig struct { // The color of the lines that give the tree its shape. GraphicsColor string `toml:"graphics_color"` @@ -257,10 +265,14 @@ func defaultTheme() ThemeConfig { } ) return ThemeConfig{ - Title: focus, - Footer: focus, - Border: focus, - GuildsTree: tree, + Title: focus, + Footer: focus, + Border: focus, + GuildsTree: GuildsTreeThemeConfig{ + TreeThemeConfig: tree, + SpeakingStyle: StyleWrapper{plain.Foreground(tcell.ColorGreen)}, + VoiceLimitStyle: StyleWrapper{plain.Foreground(tcell.ColorDodgerBlue)}, + }, MembersTree: tree, ScrollBar: ScrollBarThemeConfig{TrackStyle: dim}, MessagesList: MessagesListThemeConfig{ diff --git a/internal/ui/chat/guildstree/keymap.go b/internal/ui/chat/guildstree/keymap.go index e923311..fb02988 100644 --- a/internal/ui/chat/guildstree/keymap.go +++ b/internal/ui/chat/guildstree/keymap.go @@ -17,7 +17,7 @@ func (m Model) selectCurrentKeybind() keybind.Keybind { selectHelp := selectCurrent.Help() selectDesc := selectHelp.Desc if node := m.selectionState.CurrentNode(); node != nil { - if len(node.Children()) > 0 { + if m.expandable(node) { if node.Expanded() { selectDesc = "collapse" } else { @@ -33,9 +33,25 @@ func (m Model) selectCurrentKeybind() keybind.Keybind { return selectCurrent.WithHelp(selectHelp.Key, selectDesc) } +// toggleVoiceKeybinds returns the keybind that joins or leaves the highlighted voice channel, if it is one. +func (m Model) toggleVoiceKeybinds() []keybind.Keybind { + channel := m.voiceChannel(m.selectionState.CurrentNode()) + if channel == nil { + return nil + } + + toggleVoice := m.cfg.Keybinds.GuildsTree.ToggleVoice.Keybind + if m.voiceSession.Status().ChannelID == channel.ID { + toggleVoice = toggleVoice.WithHelp(toggleVoice.Help().Key, "leave voice") + } + + return []keybind.Keybind{toggleVoice} +} + func (m Model) ShortHelp() []keybind.Keybind { cfg := m.cfg.Keybinds.GuildsTree shortHelp := append(uitree.SelectHelp(m.root, m.selectionState.CurrentNode(), cfg.TreeKeybinds), m.selectCurrentKeybind()) + shortHelp = append(shortHelp, m.toggleVoiceKeybinds()...) if m.canCollapseParent(m.selectionState.CurrentNode()) { shortHelp = append(shortHelp, cfg.CollapseParentNode.Keybind) } @@ -52,7 +68,7 @@ func (m Model) FullHelp() [][]keybind.Keybind { {cfg.ScrollUp.Keybind, cfg.ScrollDown.Keybind, cfg.ScrollTop.Keybind, cfg.ScrollBottom.Keybind, cfg.ScrollLeft.Keybind, cfg.ScrollRight.Keybind}, selectGroup, {cfg.SelectPreviousUnread.Keybind, cfg.SelectNextUnread.Keybind}, - {cfg.YankID.Keybind}, + append([]keybind.Keybind{cfg.YankID.Keybind}, m.toggleVoiceKeybinds()...), } } diff --git a/internal/ui/chat/guildstree/model.go b/internal/ui/chat/guildstree/model.go index 28356c7..7d02ae9 100644 --- a/internal/ui/chat/guildstree/model.go +++ b/internal/ui/chat/guildstree/model.go @@ -1,18 +1,24 @@ package guildstree import ( + "cmp" + "errors" + "fmt" "log/slog" "slices" + "time" "github.com/ayn2op/arikawa/v3/discord" "github.com/ayn2op/arikawa/v3/gateway" "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/ui" uitree "github.com/ayn2op/discordo/internal/ui/tree" + "github.com/ayn2op/discordo/internal/voice" "github.com/ayn2op/ningen/v3" "github.com/ayn2op/ningen/v3/states/read" "github.com/ayn2op/tview" "github.com/ayn2op/tview/keybind" + "github.com/ayn2op/tview/richtext" "github.com/ayn2op/tview/tree" "github.com/ayn2op/tview/viewport" "github.com/gdamore/tcell/v3" @@ -25,8 +31,9 @@ type Model struct { selectionState tree.SelectionState scrollState viewport.ScrollState - cfg *config.Config - state *ningen.State + cfg *config.Config + state *ningen.State + voiceSession *voice.Session // nodes indexes the guild and channel nodes for frequent event handlers (read updates, picker navigation). // It mirrors the current rendered tree and is rebuilt on READY before nodes are added. @@ -36,10 +43,11 @@ type Model struct { func NewModel(cfg *config.Config, state *ningen.State) Model { return Model{ - root: tree.NewNode(""), - cfg: cfg, - state: state, - nodes: make(map[discord.Snowflake]*tree.Node), + root: tree.NewNode(""), + cfg: cfg, + state: state, + voiceSession: voice.New(state.State, cfg.Voice.Sensitivity), + nodes: make(map[discord.Snowflake]*tree.Node), } } @@ -57,13 +65,15 @@ func (m Model) View(focused bool) tview.Widget { m.selectionState, m.scrollState, m.cfg, - m.cfg.Theme.GuildsTree, + m.cfg.Theme.GuildsTree.TreeThemeConfig, m.cfg.UI.GuildsTree.Graphics, m.cfg.Keybinds.GuildsTree.TreeKeybinds, focused, onChange, onSelect, - ).Title("Guilds") + ). + Title("Guilds"). + Footer(m.voiceFooter()) } func (m *Model) reset() *tree.Node { @@ -238,11 +248,196 @@ func (m Model) createChannelNode(parent *tree.Node, channel discord.Channel) { m.nodes[discord.Snowflake(channel.ID)] = channelNode } +// showVoiceMembers lists who is in each voice channel of the guild under it. +func (m *Model) showVoiceMembers(guildID discord.GuildID) { + states, _ := m.state.Cabinet.VoiceStates(guildID) + slices.SortFunc(states, func(a, b discord.VoiceState) int { return cmp.Compare(a.UserID, b.UserID) }) + + channels, _ := m.state.Cabinet.Channels(guildID) + for _, channel := range channels { + node := m.nodes[discord.Snowflake(channel.ID)] + if node == nil || !isVoice(channel.Type) { + continue + } + + // The members are replaced, so what is selected cannot stay one of them. + if slices.Contains(node.Children(), m.selectionState.CurrentNode()) { + m.selectionState.SetCurrentNode(node) + } + + node.ClearChildren() + for _, state := range states { + if state.ChannelID == channel.ID { + node.AddChild(tree.NewNode(m.voiceMemberText(guildID, state)).SetReference(state.UserID)) + } + } + + // After its name, how full it is, if it can be. + line := node.Line()[:1] + if channel.VoiceUserLimit > 0 { + label := fmt.Sprintf("%d/%d", len(node.Children()), channel.VoiceUserLimit) + line = append(line, richtext.NewSegment(" ", tview.Style{}), richtext.NewSegment(label, m.cfg.Theme.GuildsTree.VoiceLimitStyle.Style)) + } + + node.SetLine(line) + } + + m.styleVoiceMembers() +} + +// voiceMemberText returns the name of the member that state is of, and whether they are muted or deafened. +func (m Model) voiceMemberText(guildID discord.GuildID, state discord.VoiceState) string { + text := state.UserID.String() + member := state.Member + if member == nil { + member, _ = m.state.Cabinet.Member(guildID, state.UserID) + } + + if member != nil { + text = cmp.Or(member.Nick, member.User.DisplayName, member.User.Username) + } + + return text + m.voiceMarkers(state) +} + +// voiceMarkers returns what shows that the user that state is of is muted or deafened. +func (m Model) voiceMarkers(state discord.VoiceState) (markers string) { + if state.Mute || state.SelfMute { + markers += m.cfg.Icons.VoiceMuted + } + + if state.Deaf || state.SelfDeaf { + markers += m.cfg.Icons.VoiceDeafened + } + + return markers +} + +// styleVoiceMembers shows who is speaking in the voice channel joined. +func (m Model) styleVoiceMembers() { + node := m.nodes[discord.Snowflake(m.voiceSession.Status().ChannelID)] + if node == nil { + return + } + + for _, member := range node.Children() { + var style tview.Style + if userID, _ := member.Reference().(discord.UserID); m.voiceSession.Speaking(userID) { + style = m.cfg.Theme.GuildsTree.SpeakingStyle.Style + } + + m.setNodeLineStyle(member, style) + } +} + +// voiceFooter returns the state of the voice channel joined or being joined and of the user in it, or "" if there is none. +func (m Model) voiceFooter() string { + status := m.voiceSession.Status() + channel, err := m.state.Cabinet.Channel(status.ChannelID) + if err != nil { + return "" + } + + name := ui.ChannelToString(*channel, m.cfg.Icons, m.state) + if me, _ := m.state.Cabinet.Me(); me != nil { + if state, _ := m.state.Cabinet.VoiceState(channel.GuildID, me.ID); state != nil { + name += m.voiceMarkers(*state) + } + } + + switch { + case !status.Joined: + return "Joining " + name + "..." + case m.voiceSession.Latency() == 0: + return "In " + name + default: + return "In " + name + " (" + m.voiceSession.Latency().Round(time.Millisecond).String() + ")" + } +} + +// voiceChannel returns the voice channel of node, or nil if it is not one. +func (m Model) voiceChannel(node *tree.Node) *discord.Channel { + if node == nil { + return nil + } + + channelID, _ := node.Reference().(discord.ChannelID) + channel, err := m.state.Cabinet.Channel(channelID) + if err != nil || !isVoice(channel.Type) { + return nil + } + + return channel +} + +// joinable returns why the voice channel of node cannot be joined, or nil if it can be. +func (m Model) joinable(node *tree.Node, channel discord.Channel) error { + switch { + case !m.state.HasPermissions(channel.ID, discord.PermissionConnect): + return errors.New("you are not permitted to connect") + // Discord does not answer a request to join a full voice channel. + case channel.VoiceUserLimit > 0 && len(node.Children()) >= int(channel.VoiceUserLimit) && !m.state.HasPermissions(channel.ID, discord.PermissionMoveMembers): + return errors.New("it is full") + } + + return nil +} + +// toggleVoice joins the voice channel of node, or leaves it if it is joined. +func (m Model) toggleVoice(node *tree.Node) tview.Cmd { + channel := m.voiceChannel(node) + switch { + case channel == nil: + return nil + case m.voiceSession.Status().ChannelID == channel.ID: + return voiceCmd("leave "+channel.Name, m.voiceSession.Leave) + } + + err := m.joinable(node, *channel) + return voiceCmd("join "+channel.Name, func() error { + if err != nil { + return err + } + + return m.voiceSession.Join(channel.ID) + }) +} + +// toggleMute starts or stops sending what the user says to the voice channel joined, if they are permitted to speak in it without holding a key. +func (m Model) toggleMute() tview.Cmd { + return voiceCmd("toggle mute", func() error { + if channelID := m.voiceSession.Status().ChannelID; channelID.IsValid() && !m.state.HasPermissions(channelID, discord.PermissionSpeak|discord.PermissionUseVAD) { + return errors.New("you are not permitted to speak by voice activity") + } + + return m.voiceSession.Toggle(false) + }) +} + +// voiceCmd returns a command that does f, and shows why it failed to do what if it did. +func voiceCmd(what string, f func() error) tview.Cmd { + return func() tview.Msg { + if err := f(); err != nil { + return ui.ErrorDialog(what, err) + } + + return nil + } +} + +// expandable reports whether selecting node expands or collapses it, which the members of a voice channel do not make it. +func (m Model) expandable(node *tree.Node) bool { + return len(node.Children()) > 0 && m.voiceChannel(node) == nil +} + +func isVoice(t discord.ChannelType) bool { + return t == discord.GuildVoice || t == discord.GuildStageVoice +} + +// setNodeLineStyle styles the name of node, which is the first segment of its line. func (m Model) setNodeLineStyle(node *tree.Node, style tview.Style) { line := node.Line() - for i := range line { - line[i].Style = style - } + line[0].Style = style node.SetLine(line) } @@ -327,6 +522,12 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { case *read.UpdateEvent: m.refreshReadStyles(msg) return m, nil + case *gateway.VoiceStateUpdateEvent: + m.showVoiceMembers(msg.GuildID) + return m, nil + case VoiceMsg: + m.styleVoiceMembers() + return m, m.Init() case NavigateMsg: return m, m.navigate(msg.ChannelID) @@ -369,6 +570,14 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { return m, nil case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.YankID.Keybind): return m, uitree.YankID(m.selectionState.CurrentNode()) + case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.ToggleVoice.Keybind): + return m, m.toggleVoice(m.selectionState.CurrentNode()) + case keybind.Matches(msg, m.cfg.Keybinds.ToggleMute.Keybind): + return m, m.toggleMute() + case keybind.Matches(msg, m.cfg.Keybinds.ToggleDeafen.Keybind): + return m, voiceCmd("toggle deafen", func() error { return m.voiceSession.Toggle(true) }) + case keybind.Matches(msg, m.cfg.Keybinds.LeaveVoice.Keybind): + return m, voiceCmd("leave voice", m.voiceSession.Leave) } } return m, nil diff --git a/internal/ui/chat/guildstree/msg.go b/internal/ui/chat/guildstree/msg.go index 82a33cf..5ed1cd7 100644 --- a/internal/ui/chat/guildstree/msg.go +++ b/internal/ui/chat/guildstree/msg.go @@ -25,6 +25,17 @@ type ChannelLoadedMsg struct { type NavigateMsg struct{ ChannelID discord.ChannelID } +// VoiceMsg is sent when who is speaking or the state of the voice channel joined changes. +type VoiceMsg struct{} + +// Init waits for that. +func (m Model) Init() tview.Cmd { + return func() tview.Msg { + <-m.voiceSession.Updates() + return VoiceMsg{} + } +} + func (m *Model) navigate(channelID discord.ChannelID) tview.Cmd { channel, err := m.state.Cabinet.Channel(channelID) if err != nil { @@ -45,7 +56,7 @@ func (m *Model) navigate(channelID discord.ChannelID) tview.Cmd { } func (m Model) selectNode(node *tree.Node) tview.Cmd { - if len(node.Children()) != 0 { + if m.expandable(node) { node.SetExpanded(!node.Expanded()) return nil } @@ -60,6 +71,7 @@ func (m Model) selectNode(node *tree.Node) tview.Cmd { } ui.SortGuildChannels(channels) m.createChannelNodes(node, channels) + m.showVoiceMembers(ref) node.Expand() case discord.ChannelID: channel, err := m.state.Cabinet.Channel(ref) diff --git a/internal/ui/chat/keymap.go b/internal/ui/chat/keymap.go index 46a3ec0..731cfd6 100644 --- a/internal/ui/chat/keymap.go +++ b/internal/ui/chat/keymap.go @@ -57,6 +57,7 @@ func (m Model) baseFullHelp() [][]keybind.Keybind { m.focusHelp(), {cfg.FocusPrevious.Keybind, cfg.FocusNext.Keybind}, {cfg.ToggleGuildsTree.Keybind, cfg.ToggleMembersTree.Keybind, cfg.ToggleChannelsPicker.Keybind}, + {cfg.ToggleMute.Keybind, cfg.ToggleDeafen.Keybind, cfg.LeaveVoice.Keybind}, {cfg.Logout.Keybind}, } } diff --git a/internal/ui/chat/messageslist/model.go b/internal/ui/chat/messageslist/model.go index ebd2381..ede8379 100644 --- a/internal/ui/chat/messageslist/model.go +++ b/internal/ui/chat/messageslist/model.go @@ -1034,11 +1034,6 @@ func messageURLs(msg discord.Message) []string { return urls } -func attachmentErr(what string, err error) tview.Msg { - slog.Error("failed to "+what, "err", err) - return ui.ModalMsg{Text: "Failed to " + what + ": " + err.Error(), Buttons: []ui.ModalButton{{Label: "OK"}}} -} - func downloadAttachment(attachment discord.Attachment, destination string) error { resp, err := http.Get(attachment.URL) if err != nil { diff --git a/internal/ui/chat/messageslist/msg.go b/internal/ui/chat/messageslist/msg.go index 749a152..2e85a89 100644 --- a/internal/ui/chat/messageslist/msg.go +++ b/internal/ui/chat/messageslist/msg.go @@ -201,10 +201,10 @@ func (m Model) openAttachment(attachment discord.Attachment) tview.Cmd { func (m Model) confirmAttachment(attachment discord.Attachment, action tview.Cmd) tview.Cmd { if !m.cfg.Attachments.AllowedMIMETypes.Has(attachment.ContentType) { - return ui.ShowModal( + return ui.ShowDialog( "This attachment type is not allowed and may be unsafe. Continue anyway?", - ui.ModalButton{Label: "No"}, - ui.ModalButton{Label: "Yes", Cmd: action}, + ui.DialogButton{Label: "No"}, + ui.DialogButton{Label: "Yes", Cmd: action}, ) } return action @@ -222,12 +222,12 @@ func openDownloadedAttachment(attachment discord.Attachment) tview.Cmd { dir := filepath.Join(consts.CacheDir(), "attachments") if err := os.MkdirAll(dir, 0o700); err != nil { - return attachmentErr("create attachments directory", err) + return ui.ErrorDialog("create attachments directory", err) } file, err := os.CreateTemp(dir, "attachment-*"+extension) if err != nil { - return attachmentErr("create attachment file", err) + return ui.ErrorDialog("create attachment file", err) } defer file.Close() @@ -235,9 +235,9 @@ func openDownloadedAttachment(attachment discord.Attachment) tview.Cmd { if err := downloadAttachment(attachment, path); err != nil { os.Remove(path) - return attachmentErr("download attachment", err) + return ui.ErrorDialog("download attachment", err) } else if err := open.Start(path); err != nil { - return attachmentErr("open attachment file", err) + return ui.ErrorDialog("open attachment file", err) } return nil @@ -251,11 +251,11 @@ func saveAttachment(attachment discord.Attachment) tview.Cmd { return nil } if err != nil { - return attachmentErr("select attachment destination", err) + return ui.ErrorDialog("select attachment destination", err) } if err := downloadAttachment(attachment, destination); err != nil { - return attachmentErr("download attachment", err) + return ui.ErrorDialog("download attachment", err) } return nil } @@ -264,7 +264,7 @@ func saveAttachment(attachment discord.Attachment) tview.Cmd { func openURL(url string) tview.Cmd { return func() tview.Msg { if err := open.Start(url); err != nil { - return attachmentErr("open URL", err) + return ui.ErrorDialog("open URL", err) } return nil } @@ -306,10 +306,10 @@ func (m Model) confirmDelete() tview.Cmd { return nil } message := *selectedMessage - return ui.ShowModal( + return ui.ShowDialog( "Are you sure you want to delete this message?", - ui.ModalButton{Label: "Yes", Cmd: m.requestDelete(message)}, - ui.ModalButton{Label: "No"}, + ui.DialogButton{Label: "Yes", Cmd: m.requestDelete(message)}, + ui.DialogButton{Label: "No"}, ) } diff --git a/internal/ui/chat/model.go b/internal/ui/chat/model.go index bd33820..64ae006 100644 --- a/internal/ui/chat/model.go +++ b/internal/ui/chat/model.go @@ -190,7 +190,7 @@ func (m *Model) cycleFocus(step pane) { } func (m Model) Init() tview.Cmd { - return tview.Batch(openState(m.state), listen(m.events), tview.RequestTerminalInfo()) + return tview.Batch(openState(m.state), listen(m.events), m.guildsTree.Init(), tview.RequestTerminalInfo()) } // Update returns m changed in response to msg and a command to run, or nil. @@ -198,6 +198,8 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { switch msg := msg.(type) { case gateway.Event: return m, tview.Batch(m.applyEvent(msg), listen(m.events)) + case guildstree.VoiceMsg: + return m, m.updatePane(paneGuildsTree, msg) case tview.FocusMsg: m.windowUnfocused = !msg.Focused return m, nil @@ -290,6 +292,9 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { case keybind.Matches(msg, m.cfg.Keybinds.ToggleChannelsPicker.Keybind): m.togglePicker() return m, nil + // The guilds tree is what the voice channel joined is in. + case keybind.Matches(msg, m.cfg.Keybinds.ToggleMute.Keybind, m.cfg.Keybinds.ToggleDeafen.Keybind, m.cfg.Keybinds.LeaveVoice.Keybind): + return m, m.updatePane(paneGuildsTree, msg) case keybind.Matches(msg, m.cfg.Keybinds.Logout.Keybind): return m, tview.Sequence(closeState(m.state), logout()) diff --git a/internal/ui/chat/state.go b/internal/ui/chat/state.go index 05297bf..f27771d 100644 --- a/internal/ui/chat/state.go +++ b/internal/ui/chat/state.go @@ -138,6 +138,8 @@ func (m *Model) applyEvent(event gateway.Event) tview.Cmd { case *read.UpdateEvent: return m.updatePane(paneGuildsTree, event) + case *gateway.VoiceStateUpdateEvent: + return m.updatePane(paneGuildsTree, event) } return nil } diff --git a/internal/ui/dialog.go b/internal/ui/dialog.go new file mode 100644 index 0000000..f6da19a --- /dev/null +++ b/internal/ui/dialog.go @@ -0,0 +1,28 @@ +package ui + +import ( + "log/slog" + + "github.com/ayn2op/tview" +) + +type DialogButton struct { + Label string + Cmd tview.Cmd + KeepOpen bool +} + +type DialogMsg struct { + Text string + Buttons []DialogButton +} + +func ShowDialog(text string, buttons ...DialogButton) tview.Cmd { + return func() tview.Msg { return DialogMsg{Text: text, Buttons: buttons} } +} + +// ErrorDialog logs that what failed with err and returns the dialog that says so. +func ErrorDialog(what string, err error) DialogMsg { + slog.Error("failed to "+what, "err", err) + return DialogMsg{Text: "Failed to " + what + ": " + err.Error(), Buttons: []DialogButton{{Label: "OK"}}} +} diff --git a/internal/ui/login/msg.go b/internal/ui/login/msg.go index 13bc088..89750e8 100644 --- a/internal/ui/login/msg.go +++ b/internal/ui/login/msg.go @@ -24,8 +24,8 @@ type selectTabMsg int func showErrorDialog(err error) tview.Cmd { slog.Error("failed to login", "err", err) message := err.Error() - return ui.ShowModal(message, - ui.ModalButton{Label: "Copy", Cmd: setClipboard(message), KeepOpen: true}, - ui.ModalButton{Label: "Close"}, + return ui.ShowDialog(message, + ui.DialogButton{Label: "Copy", Cmd: setClipboard(message), KeepOpen: true}, + ui.DialogButton{Label: "Close"}, ) } diff --git a/internal/ui/modal.go b/internal/ui/modal.go deleted file mode 100644 index f0c08c4..0000000 --- a/internal/ui/modal.go +++ /dev/null @@ -1,18 +0,0 @@ -package ui - -import "github.com/ayn2op/tview" - -type ModalButton struct { - Label string - Cmd tview.Cmd - KeepOpen bool -} - -type ModalMsg struct { - Text string - Buttons []ModalButton -} - -func ShowModal(text string, buttons ...ModalButton) tview.Cmd { - return func() tview.Msg { return ModalMsg{Text: text, Buttons: buttons} } -} diff --git a/internal/ui/root/model.go b/internal/ui/root/model.go index 4752ee5..a08ab34 100644 --- a/internal/ui/root/model.go +++ b/internal/ui/root/model.go @@ -42,8 +42,8 @@ type Model struct { chat chat.Model // helpVisible reports whether help is shown below the inner model, and helpShowAll whether it shows every keybind. helpVisible, helpShowAll bool - // modal is the request shown in a dialog, or nil when none is open, and dialogFocus its focused button. - modal *ui.ModalMsg + // dialog is the dialog shown, or nil when none is open, and dialogFocus its focused button. + dialog *ui.DialogMsg dialogFocus int // resize is the last size of the window, or nil before the first, kept for the models shown later. resize tview.ResizeMsg @@ -91,20 +91,20 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { m.showLogin(), deleteToken(), ) - case ui.ModalMsg: - if m.modal == nil { - m.modal, m.dialogFocus = &msg, 0 + case ui.DialogMsg: + if m.dialog == nil { + m.dialog, m.dialogFocus = &msg, 0 } return m, nil case dialogFocusMsg: m.dialogFocus = int(msg) return m, nil case dialogDoneMsg: - return m, m.finishModal(int(msg)) + return m, m.finishDialog(int(msg)) case tview.KeyMsg: // The dialog takes all input while it is open, so nothing behind it reacts. - if m.modal != nil { + if m.dialog != nil { return m, nil } switch { @@ -119,10 +119,10 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { case keybind.Matches(msg, m.cfg.Keybinds.Suspend.Keybind): return m, suspend() case keybind.Matches(msg, m.cfg.Keybinds.Quit.Keybind): - return m, tview.Batch(m.updateScreen(chat.QuitMsg{}), tview.Quit()) + return m, tview.Sequence(m.updateScreen(chat.QuitMsg{}), tview.Quit()) } case tview.MouseMsg, tview.PasteMsg: - if m.modal != nil { + if m.dialog != nil { return m, nil } case tview.ResizeMsg: @@ -149,7 +149,7 @@ func (m Model) helpHeight() int { return m.helpView().Layout(layout.Limits{Infinite: layout.Axes{Height: true}}).Height } -// View shows the inner model above help, with the modal dialog on top of both when one is open. +// View shows the inner model above help, with the dialog on top of both when one is open. func (m Model) View() tview.Widget { var innerView, helpView tview.Widget switch m.screen { @@ -163,7 +163,7 @@ func (m Model) View() tview.Widget { helpView = column.New(padded).Height(layout.Fixed(max(m.helpHeight(), 1))) } content := column.New(innerView, helpView) - if m.modal == nil { + if m.dialog == nil { return content } return stack.New(inert.New(content), backdrop.New().Style(m.cfg.Theme.Dialog.BackgroundStyle.Style), m.dialogView()) @@ -187,15 +187,15 @@ type ( dialogDoneMsg int ) -// dialogView returns the dialog for the open modal request. +// dialogView returns the view of the open dialog. func (m Model) dialogView() tview.Widget { - labels := make([]string, len(m.modal.Buttons)) - for i, button := range m.modal.Buttons { + labels := make([]string, len(m.dialog.Buttons)) + for i, button := range m.dialog.Buttons { labels[i] = button.Label } style := m.cfg.Theme.Dialog.Style.Style d := dialog.New(). - Text(m.modal.Text). + Text(m.dialog.Text). Buttons(labels...). Focus(m.dialogFocus). ButtonStyle(style). diff --git a/internal/ui/root/msg.go b/internal/ui/root/msg.go index f5102f8..93ba754 100644 --- a/internal/ui/root/msg.go +++ b/internal/ui/root/msg.go @@ -78,20 +78,20 @@ func (m *Model) showChat(token string) tview.Cmd { return m.show(init) } -// show closes any open modal after the shown model changed and returns what it needs done, with init its first command. +// show closes any open dialog after the shown model changed and returns what it needs done, with init its first command. func (m *Model) show(init tview.Cmd) tview.Cmd { - m.modal = nil + m.dialog = nil return tview.Batch(tview.SetTitle(consts.Name), init) } -func (m *Model) finishModal(index int) tview.Cmd { - if m.modal == nil || index < 0 || index >= len(m.modal.Buttons) { - m.modal = nil +func (m *Model) finishDialog(index int) tview.Cmd { + if m.dialog == nil || index < 0 || index >= len(m.dialog.Buttons) { + m.dialog = nil return nil } - button := m.modal.Buttons[index] + button := m.dialog.Buttons[index] if !button.KeepOpen { - m.modal = nil + m.dialog = nil } return button.Cmd } diff --git a/internal/voice/session.go b/internal/voice/session.go new file mode 100644 index 0000000..abcfcde --- /dev/null +++ b/internal/voice/session.go @@ -0,0 +1,440 @@ +//go:build !no_voice + +package voice + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "log/slog" + "math" + "sync" + "sync/atomic" + "time" + + "github.com/ayn2op/arikawa/v3/discord" + "github.com/ayn2op/arikawa/v3/gateway" + "github.com/ayn2op/arikawa/v3/state" + "github.com/ayn2op/arikawa/v3/utils/ws" + "github.com/ayn2op/arikawa/v3/voice" + "github.com/ayn2op/arikawa/v3/voice/udp" + "github.com/ayn2op/arikawa/v3/voice/voicegateway" + "github.com/gen2brain/malgo" + dave "github.com/thomas-vilte/dave-go/session" + "github.com/tphakala/go-opus/opus" +) + +const ( + timeout = 15 * time.Second + // hangover is how many frames are still sent after the last that was loud enough, for the ends of words not to be cut off. + hangover = 15 +) + +// audio is what the audio devices are opened with. It is of the first backend that works but the null one, which malgo would otherwise silently use when the system has no audio library. +var audio = sync.OnceValues(func() (*malgo.AllocatedContext, error) { + return malgo.InitContext([]malgo.Backend{ + malgo.BackendWasapi, malgo.BackendDsound, malgo.BackendWinmm, + malgo.BackendCoreaudio, + malgo.BackendSndio, malgo.BackendAudio4, malgo.BackendOss, + malgo.BackendPulseaudio, malgo.BackendAlsa, malgo.BackendJack, + }, malgo.ContextConfig{}, nil) +}) + +// startDevice starts a playback or capture device, which calls data with what to fill or with what was captured. +func startDevice(kind malgo.DeviceType, data func(output, input []byte)) (*malgo.Device, error) { + ctx, err := audio() + if err != nil { + return nil, err + } + + config := malgo.DefaultDeviceConfig(kind) + config.SampleRate = sampleRate + config.PeriodSizeInMilliseconds = 10 + config.Playback.Format, config.Playback.Channels = malgo.FormatS16, channels + config.Capture.Format, config.Capture.Channels = malgo.FormatS16, channels + + device, err := malgo.InitDevice(ctx.Context, config, malgo.DeviceCallbacks{ + Data: func(output, input []byte, _ uint32) { data(output, input) }, + }) + if err != nil { + return nil, err + } + + if err := device.Start(); err != nil { + device.Uninit() + return nil, err + } + + return device, nil +} + +// Session is the voice channel that the user is in, if any. +type Session struct { + state *state.State + // threshold is how loud a sample of the microphone must be for what is said to be sent. + threshold int + // updates is sent to when the status or who is speaking changes. + updates chan struct{} + // mic is sent what the microphone captures. + mic chan []byte + + // mu is held while joining, leaving and muting, which are not done while another is. + mu sync.Mutex + // session is nil until the first voice channel is joined. + session atomic.Pointer[voice.Session] + // playback plays while a voice channel is joined, and capture captures while the user is not muted in it. + playback, capture *malgo.Device + + stateMu sync.Mutex + status Status + speaking map[discord.UserID]bool + speakers map[uint32]*speaker +} + +// New returns a Session that sends what the microphone captures louder than sensitivity, in dB. +func New(state *state.State, sensitivity float64) *Session { + s := &Session{ + state: state, + threshold: int(math.Pow(10, sensitivity/20) * math.MaxInt16), + updates: make(chan struct{}, 1), + mic: make(chan []byte, 4), + speaking: make(map[discord.UserID]bool), + speakers: make(map[uint32]*speaker), + } + state.AddHandler(s.follow) + + return s +} + +// follow keeps up with the user being moved to another voice channel or disconnected, by someone else or by connecting from another client. +func (s *Session) follow(event *gateway.VoiceStateUpdateEvent) { + me, _ := s.state.Cabinet.Me() + if me == nil || event.UserID != me.ID { + return + } + + s.mu.Lock() + defer s.mu.Unlock() + + status, own := s.Status(), event.SessionID == s.state.Ready().SessionID + switch { + case !status.Joined || own && event.ChannelID == status.ChannelID: + case own && event.ChannelID.IsValid(): + s.setStatus(Status{ChannelID: event.ChannelID, Joined: true}) + default: + s.left() + } +} + +// Updates is sent to when the status or who is speaking changes. +func (s *Session) Updates() <-chan struct{} { + return s.updates +} + +func (s *Session) Status() Status { + s.stateMu.Lock() + defer s.stateMu.Unlock() + + return s.status +} + +// Speaking reports whether the user is speaking in the voice channel joined. +func (s *Session) Speaking(userID discord.UserID) bool { + s.stateMu.Lock() + defer s.stateMu.Unlock() + + return s.speaking[userID] +} + +// Latency returns how long the voice server of the voice channel joined takes to answer, or 0 if it is not known. +func (s *Session) Latency() time.Duration { + if session := s.session.Load(); session != nil { + return session.Latency() + } + + return 0 +} + +// update changes the state with f and tells Updates if f reports that it did. +func (s *Session) update(f func() bool) { + s.stateMu.Lock() + changed := f() + s.stateMu.Unlock() + + if changed { + select { + case s.updates <- struct{}{}: + default: + } + } +} + +// setStatus changes the status, which whoever spoke and was heard does not outlast. +func (s *Session) setStatus(status Status) { + s.update(func() bool { + s.status = status + clear(s.speaking) + clear(s.speakers) + return true + }) +} + +func (s *Session) setSpeaking(userID discord.UserID, speaking bool) { + s.update(func() bool { + changed := s.speaking[userID] != speaking + s.speaking[userID] = speaking + return changed + }) +} + +// setDevice starts device, which then calls data, if on, and stops it otherwise. +func setDevice(device **malgo.Device, on bool, kind malgo.DeviceType, data func(output, input []byte)) (err error) { + switch { + case on && *device == nil: + *device, err = startDevice(kind, data) + case !on && *device != nil: + (*device).Uninit() + *device = nil + } + + return err +} + +// setDevices captures what the user says unless mute, and plays what is said unless deafen. +func (s *Session) setDevices(mute, deafen bool) error { + err := errors.Join( + setDevice(&s.capture, !mute, malgo.Capture, func(_, input []byte) { s.queue(bytes.Clone(input)) }), + setDevice(&s.playback, !deafen, malgo.Playback, s.mix), + ) + if mute { + // Silence makes what is being said end. + s.queue(make([]byte, 2*hangover*frameBytes)) + } + + return err +} + +// left stops the devices, in no voice channel. +func (s *Session) left() { + s.setDevices(true, true) + s.setStatus(Status{}) +} + +// Join joins the voice channel, muted, leaving the one that the user is in. +func (s *Session) Join(channelID discord.ChannelID) (err error) { + if !s.mu.TryLock() { + return nil + } + defer s.mu.Unlock() + + s.setStatus(Status{ChannelID: channelID}) + + defer func() { + // Discord may have the user in the voice channel regardless, and joining it again would then do nothing. + if err != nil { + s.leave() + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + session := s.session.Load() + if session == nil { + if session, err = voice.NewSession(s.state, s.state.Handler); err != nil { + return err + } + + session.DAVE = dave.CreateFunc() + session.AddHandler(func(event *ws.BackgroundErrorEvent) { slog.Debug("voice gateway error", "err", event.Err) }) + s.session.Store(session) + + go s.receive(session) + go s.send(session) + } + + if err := s.setDevices(true, false); err != nil { + return err + } + + if err := session.JoinChannel(ctx, channelID, true, false); err != nil { + return err + } + + // The voice server sends nothing before it is told this. + if err := session.Speaking(ctx, voicegateway.Microphone); err != nil { + return err + } + + s.setStatus(Status{ChannelID: channelID, Joined: true}) + + return nil +} + +// Leave leaves the voice channel that the user is in, if any. +func (s *Session) Leave() error { + if !s.mu.TryLock() { + return nil + } + defer s.mu.Unlock() + + return s.leave() +} + +func (s *Session) leave() error { + defer s.left() + + session := s.session.Load() + if session == nil { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + return session.Leave(ctx) +} + +// Toggle mutes or unmutes the user in the voice channel joined, if any, or deafens or undeafens them if deafen. Deafening also mutes, and unmuting also undeafens. +func (s *Session) Toggle(deafen bool) error { + if !s.mu.TryLock() { + return nil + } + defer s.mu.Unlock() + + status := s.Status() + channel, err := s.state.Cabinet.Channel(status.ChannelID) + if err != nil || !status.Joined { + return nil + } + + mute, deafen := deafen || s.capture != nil, deafen && s.playback != nil + if err := s.setDevices(mute, deafen); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + return s.state.SendGateway(ctx, &gateway.UpdateVoiceStateCommand{ + GuildID: channel.GuildID, + ChannelID: channel.ID, + SelfMute: mute, + SelfDeaf: deafen, + }) +} + +// queue queues what was captured to be sent, in place of the oldest if what is queued is not being sent fast enough, for what is said not to be sent ever later. +func (s *Session) queue(pcm []byte) { + for { + select { + case s.mic <- pcm: + return + default: + } + + select { + case <-s.mic: + default: + } + } +} + +// mix fills output with what everyone says. It is what the playback device calls. +func (s *Session) mix(output, _ []byte) { + s.stateMu.Lock() + defer s.stateMu.Unlock() + + for _, speaker := range s.speakers { + speaker.mix(output) + } +} + +// receive buffers what each user says in the voice channels that session joins. +func (s *Session) receive(session *voice.Session) { + for { + packet, err := session.ReadPacket() + if errors.Is(err, udp.ErrDecryptionFailed) { + continue + } + + if err != nil { + // In no voice channel, or joining one. + time.Sleep(100 * time.Millisecond) + continue + } + + s.stateMu.Lock() + speaker := s.speakers[packet.SSRC()] + if speaker == nil { + if speaker, err = newSpeaker(packet.Sequence()); err != nil { + s.stateMu.Unlock() + slog.Error("failed to create Opus decoder", "err", err) + return + } + + s.speakers[packet.SSRC()] = speaker + } + + s.stateMu.Unlock() + + if err := speaker.write(packet.Sequence(), packet.Opus); err != nil { + slog.Debug("failed to decode voice packet", "err", err, "ssrc", packet.SSRC()) + } + + s.setSpeaking(session.User(packet.SSRC()), string(packet.Opus) != voice.Silence) + } +} + +// send sends what the microphone captures to the voice channels that session joins, while it is loud enough. +func (s *Session) send(session *voice.Session) { + encoder, err := opus.NewEncoder(opus.EncoderConfig{SampleRate: sampleRate, Channels: channels}) + if err != nil { + slog.Error("failed to create Opus encoder", "err", err) + return + } + + me, _ := s.state.Cabinet.Me() + + var ( + pcm []byte + samples = make([]int16, frameSamples) + packet = make([]byte, 4000) + silence = []byte(voice.Silence) + // quiet is how many frames ago the last that was loud enough was. + quiet = hangover + ) + + for captured := range s.mic { + for pcm = append(pcm, captured...); len(pcm) >= frameBytes; pcm = pcm[frameBytes:] { + quiet++ + for i := range samples { + samples[i] = int16(binary.LittleEndian.Uint16(pcm[2*i:])) + if int(samples[i]) >= s.threshold || -int(samples[i]) >= s.threshold { + quiet = 0 + } + } + + var err error + switch { + case quiet < hangover: + var n int + if n, err = encoder.Encode(samples, packet); err == nil { + _, err = session.Write(packet[:n]) + } + case quiet < hangover+5: + // A few frames of silence are for what was said not to be interpolated with what is said next. + _, err = session.Write(silence) + } + + if err != nil { + slog.Debug("failed to send voice packet", "err", err) + } + + if me != nil { + s.setSpeaking(me.ID, quiet < hangover) + } + } + } +} diff --git a/internal/voice/session_no_voice.go b/internal/voice/session_no_voice.go new file mode 100644 index 0000000..1338821 --- /dev/null +++ b/internal/voice/session_no_voice.go @@ -0,0 +1,27 @@ +//go:build no_voice + +package voice + +import ( + "errors" + "time" + + "github.com/ayn2op/arikawa/v3/discord" + "github.com/ayn2op/arikawa/v3/state" +) + +// Session joins no voice channel. +type Session struct{} + +func New(*state.State, float64) *Session { return &Session{} } + +func (*Session) Updates() <-chan struct{} { return nil } +func (*Session) Status() Status { return Status{} } +func (*Session) Speaking(discord.UserID) bool { return false } +func (*Session) Latency() time.Duration { return 0 } +func (*Session) Leave() error { return nil } +func (*Session) Toggle(bool) error { return nil } + +func (*Session) Join(discord.ChannelID) error { + return errors.New("discordo was built without voice") +} diff --git a/internal/voice/speaker.go b/internal/voice/speaker.go new file mode 100644 index 0000000..abb8b07 --- /dev/null +++ b/internal/voice/speaker.go @@ -0,0 +1,120 @@ +//go:build !no_voice + +package voice + +import ( + "encoding/binary" + "sync" + "time" + + "github.com/tphakala/go-opus/opus" +) + +const ( + sampleRate = 48000 + channels = 2 + // frameSamples and frameBytes are the sizes of 20ms of PCM. + frameSamples = sampleRate / 50 * channels + frameBytes = frameSamples * 2 + + // cushion is how much is buffered before it is played, so that packets arriving unevenly do not make gaps. + cushion = 3 * frameBytes + // maxBuffered is how much is buffered before the oldest is dropped rather than fall behind. + maxBuffered = 10 * frameBytes + // maxLost is how many packets in a row are made up for. + maxLost = 3 +) + +// speaker decodes and buffers what one user says, to be mixed into what is played. +type speaker struct { + mu sync.Mutex + decoder *opus.Decoder + pcm []int16 + buf []byte + // playing is whether the cushion was buffered and what is left of it is being played. + playing bool + // seq is the sequence number of the last packet, and last when it arrived. + seq uint16 + last time.Time +} + +// newSpeaker returns a speaker whose first packet has the sequence number seq. +func newSpeaker(seq uint16) (*speaker, error) { + decoder, err := opus.NewDecoder(sampleRate, channels) + if err != nil { + return nil, err + } + + // 120ms is the longest an Opus packet can be. + return &speaker{decoder: decoder, pcm: make([]int16, 6*frameSamples), seq: seq - 1}, nil +} + +// decode appends what packet decodes to, or a made up frame if it is nil. +func (s *speaker) decode(packet []byte) error { + pcm := s.pcm + if packet == nil { + pcm = pcm[:frameSamples] + } + + n, err := s.decoder.Decode(packet, pcm) + if err != nil { + return err + } + + for _, sample := range pcm[:n*channels] { + s.buf = binary.LittleEndian.AppendUint16(s.buf, uint16(sample)) + } + + return nil +} + +// write buffers the packet with the sequence number seq. +func (s *speaker) write(seq uint16, packet []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Make up for the few packets that never arrived. One that arrives late or twice is not worth playing. + gap := seq - s.seq + if gap == 0 || gap > 1<<15 && time.Since(s.last) < time.Second { + return nil + } + + if gap > 1 && gap <= maxLost+1 { + for range gap - 1 { + if err := s.decode(nil); err != nil { + return err + } + } + } + + s.seq, s.last = seq, time.Now() + + if err := s.decode(packet); err != nil { + return err + } + + if len(s.buf) > maxBuffered { + s.buf = s.buf[len(s.buf)-cushion:] + } + + return nil +} + +// mix adds what is buffered to output, once the cushion is. +func (s *speaker) mix(output []byte) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.playing = s.playing || len(s.buf) >= cushion; !s.playing { + return + } + + n := min(len(output), len(s.buf)) + for i := 0; i+1 < n; i += 2 { + sum := int32(int16(binary.LittleEndian.Uint16(output[i:]))) + int32(int16(binary.LittleEndian.Uint16(s.buf[i:]))) + binary.LittleEndian.PutUint16(output[i:], uint16(max(-1<<15, min(1<<15-1, sum)))) + } + + s.buf = s.buf[n:] + s.playing = len(s.buf) > 0 +} diff --git a/internal/voice/voice.go b/internal/voice/voice.go new file mode 100644 index 0000000..40e8e61 --- /dev/null +++ b/internal/voice/voice.go @@ -0,0 +1,11 @@ +// Package voice joins voice channels, plays what is said in them and sends what the user says. Built with the no_voice tag, it does none of that and needs no C compiler. +package voice + +import "github.com/ayn2op/arikawa/v3/discord" + +// Status is what a Session is doing. +type Status struct { + // ChannelID is the voice channel joined or being joined, if any. + ChannelID discord.ChannelID + Joined bool +}