feat: play voice messages

Closes #493
This commit is contained in:
ayn2op 2026-10-11 23:47:04 -04:00
parent 95f247def8
commit 6465a1c5f2
15 changed files with 559 additions and 37 deletions

View file

@ -819,6 +819,22 @@ Download one of the selected message's attachments.
Default: `"D"`
### keybinds.messages_list.play
Play, pause or resume the selected voice message.
Default: `"p"`
### keybinds.messages_list.seek_backward
Seek the voice message that is playing or paused.
Default: `["h","left"]`
### keybinds.messages_list.seek_forward
Default: `["l","right"]`
### keybinds.messages_list.yank_content
Yank (copy) the selected message's content/url/id.

4
go.mod
View file

@ -13,9 +13,9 @@ 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-20261011215609-c5c459e53baa
github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e
github.com/ayn2op/tview v0.0.0-20261011173501-8bd5978952e4
github.com/ayn2op/tview v0.0.0-20261012034741-d8b2704e2b8e
github.com/bogdanfinn/fhttp v0.6.9
github.com/bogdanfinn/tls-client v1.16.0
github.com/gdamore/tcell/v3 v3.6.0

8
go.sum
View file

@ -10,12 +10,12 @@ 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-20261011215609-c5c459e53baa h1:uBfUPNrKAwrKweO+vzZSaI2mawNhRZ3QFZuH7JKenq4=
github.com/ayn2op/arikawa/v3 v3.0.0-20261011215609-c5c459e53baa/go.mod h1:YARcqZmk7hDjXB+lexlXX4wDFHULafbGnF0gsD0tuNw=
github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de h1:bO825TKutshlGQ/AsH6uYpZVjHQhDLElctgrh/T90aU=
github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de/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=
github.com/ayn2op/tview v0.0.0-20261011173501-8bd5978952e4/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E=
github.com/ayn2op/tview v0.0.0-20261012034741-d8b2704e2b8e h1:uCt61XkJ0DqRiBd5ozXZVd2xWlNFcA0rR2hew9lAZx0=
github.com/ayn2op/tview v0.0.0-20261012034741-d8b2704e2b8e/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E=
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=

View file

@ -123,6 +123,11 @@ type MessagesListKeybinds struct {
OpenInBrowser Keybind `toml:"open_in_browser"`
// Download one of the selected message's attachments.
Download Keybind `toml:"download"`
// Play, pause or resume the selected voice message.
Play Keybind `toml:"play"`
// Seek the voice message that is playing or paused.
SeekBackward Keybind `toml:"seek_backward"`
SeekForward Keybind `toml:"seek_forward"`
// Yank (copy) the selected message's content/url/id.
YankContent Keybind `toml:"yank_content"`
@ -280,6 +285,9 @@ func defaultKeybinds() Keybinds {
Open: key("open", "o"),
OpenInBrowser: key("open browser", "O"),
Download: key("download", "D"),
Play: key("play", "p"),
SeekBackward: key("seek back", "h", "left"),
SeekForward: key("seek forward", "l", "right"),
YankContent: key("copy text", "y"),
YankURL: key("copy url", "u"),

View file

@ -23,6 +23,9 @@ func (m Model) ShortHelp() []keybind.Keybind {
if len(selectedMessage.Attachments) != 0 || len(messageURLs(*selectedMessage)) != 0 {
help = append(help, cfg.Open.Keybind)
}
if isVoiceMessage(*selectedMessage) {
help = append(help, cfg.Play.Keybind)
}
}
return help
@ -54,6 +57,13 @@ func (m Model) FullHelp() [][]keybind.Keybind {
if len(message.Attachments) != 0 {
attachments = append(attachments, cfg.Download.Keybind)
}
if isVoiceMessage(*message) {
attachments = append(attachments, cfg.Play.Keybind)
}
}
if m.voiceStatus.ID.IsValid() {
attachments = append(attachments, cfg.SeekBackward.Keybind, cfg.SeekForward.Keybind)
}
actions = append(actions, cfg.Cancel.Keybind)

View file

@ -15,19 +15,25 @@ import (
"github.com/ayn2op/tview/layout"
"github.com/ayn2op/arikawa/v3/api"
"github.com/ayn2op/arikawa/v3/discord"
md "github.com/ayn2op/arikawa/v3/markdown"
"github.com/ayn2op/arikawa/v3/state"
"github.com/ayn2op/arikawa/v3/utils/httputil"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/discordo/internal/markdown"
"github.com/ayn2op/discordo/internal/ui"
"github.com/ayn2op/discordo/internal/voice"
"github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview"
tviewimage "github.com/ayn2op/tview/image"
"github.com/ayn2op/tview/keybind"
"github.com/ayn2op/tview/list"
"github.com/ayn2op/tview/richtext"
"github.com/ayn2op/tview/row"
"github.com/ayn2op/tview/scrollbar"
"github.com/ayn2op/tview/slider"
"github.com/ayn2op/tview/text"
"github.com/ayn2op/tview/textview"
"github.com/ayn2op/tview/viewport"
"github.com/gdamore/tcell/v3"
@ -57,6 +63,9 @@ type Model struct {
items []messageItem
renderer *markdown.Renderer
// voicePlayer plays the voice message played last, and voiceStatus is what it last said of it.
voicePlayer *voice.Player
voiceStatus voice.PlayerStatus
// kitty reports whether previews are drawn with kitty's graphics protocol.
// kittyImages counts the images sent to the terminal.
@ -72,6 +81,7 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
cfg: cfg,
state: state,
renderer: markdown.NewRenderer(cfg),
voicePlayer: voice.NewPlayer(),
}
ml.scrollState.ScrollToEnd()
@ -241,6 +251,10 @@ func (m Model) buildItem(index int) tview.Widget {
view := &messageView{render: func(width int) richtext.Text {
return m.renderMessage(message, m.cfg.Theme.MessagesList.MessageStyle.Style, width)
}}
if isVoiceMessage(message) {
view.player = m.voiceMessageView(message.Attachments[0])
}
for _, s := range previewSources(message) {
if preview, ok := item.previews[s.proxy]; ok {
view.images = append(view.images, preview)
@ -251,9 +265,11 @@ func (m Model) buildItem(index int) tview.Widget {
return item.view
}
// messageView renders a message followed by its image previews, caching the wrapped lines until the width changes.
// messageView renders a message followed by its voice message player, if it is one, and its image previews, caching the wrapped lines until the width changes.
type messageView struct {
render func(width int) richtext.Text
render func(width int) richtext.Text
// player is a row high, or nil.
player tview.Widget
images []tviewimage.Widget
width int
rendered bool
@ -278,6 +294,10 @@ func (v *messageView) Layout(limits layout.Limits) layout.Size {
return layout.Sized(limits, layout.Fill, layout.Shrink, func(limits layout.Limits) layout.Size {
v.at(limits.Max.Width)
rows := len(v.lines)
if v.player != nil {
rows++
}
for _, preview := range v.images {
_, height := preview.Size()
rows += height.Cells()
@ -289,6 +309,11 @@ func (v *messageView) Layout(limits layout.Limits) layout.Size {
func (v *messageView) Draw(screen tview.Screen, area tview.Rectangle) {
v.at(area.Width).Draw(screen, tview.Rectangle{X: area.X, Y: area.Y, Width: area.Width, Height: len(v.lines)})
y := area.Y + len(v.lines)
if v.player != nil {
v.player.Draw(screen, tview.Rectangle{X: area.X, Y: y, Width: area.Width, Height: 1})
y++
}
for _, preview := range v.images {
_, height := preview.Size()
preview.Draw(screen, tview.Rectangle{X: area.X, Y: y, Width: area.Width, Height: height.Cells()})
@ -296,7 +321,14 @@ func (v *messageView) Draw(screen tview.Screen, area tview.Rectangle) {
}
}
func (v *messageView) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg }
// Handle passes msg to the player, for its waveform to be clicked.
func (v *messageView) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
if v.player == nil {
return msg
}
return v.player.Handle(msg, tview.Rectangle{X: area.X, Y: area.Y + len(v.lines), Width: area.Width, Height: 1})
}
// dateSeparator is a line with the date in the middle, filled to the width it is drawn at.
type dateSeparator struct {
@ -556,6 +588,11 @@ func (m Model) drawDefaultMessage(builder *richtext.Builder, message discord.Mes
m.drawEmbeds(builder, message, baseStyle, width, contentRoot, contentSource)
attachmentStyle := tview.MergeStyle(baseStyle, m.cfg.Theme.MessagesList.AttachmentStyle.Style)
// A voice message is shown as its player.
if isVoiceMessage(message) {
return
}
for _, a := range message.Attachments {
builder.NewLine()
if m.cfg.Attachments.ShowLinks {
@ -568,6 +605,72 @@ func (m Model) drawDefaultMessage(builder *richtext.Builder, message discord.Mes
}
}
const (
symbolPlay = "▶"
symbolPause = "⏸"
// positionSeparator is between how much of a voice message was played and how long it is.
positionSeparator = " / "
// waveformBars is what the waveform of a voice message is drawn with, from quiet to loud.
waveformBars = "▁▂▃▄▅▆▇█"
// waveformWidth is how many bars a waveform is drawn with at most.
waveformWidth = 32
// seekStep is how far a key seeks a voice message.
seekStep = time.Second
)
// waveformRail returns the bars the waveform of a voice message is drawn with.
func waveformRail(waveform []byte) []string {
bars := []rune(waveformBars)
var rail []string
for chunk := range slices.Chunk(waveform, len(waveform)/waveformWidth+1) {
rail = append(rail, string(bars[int(slices.Max(chunk))*len(bars)/256]))
}
return rail
}
func formatDuration(d time.Duration) string {
seconds := int(d.Round(time.Second).Seconds())
return fmt.Sprintf("%d:%02d", seconds/60, seconds%60)
}
// voiceMessageView returns the player of the voice message a: whether it is playing, its waveform with what was played of it, and how long it is.
func (m Model) voiceMessageView(a discord.Attachment) tview.Widget {
duration := time.Duration(a.DurationSecs * float64(time.Second))
symbol, label := symbolPlay, formatDuration(duration)
status := m.voiceStatus
loaded := status.ID == a.ID
if loaded {
label = formatDuration(status.Position) + positionSeparator + label
}
if loaded && status.Playing {
symbol = symbolPause
}
rail := waveformRail(a.Waveform)
style := m.cfg.Theme.MessagesList.AttachmentStyle.Style
// Only the voice message that is loaded can be sought.
var onSeek func(time.Duration) tview.Msg
if loaded {
onSeek = func(position time.Duration) tview.Msg { return seekMsg(position) }
}
waveform := slider.New(0, duration, status.Position, onSeek).
Width(layout.Fixed(len(rail))).
Rail(func(cell, handle int) (string, tview.Style) {
if loaded && cell <= handle {
return rail[cell], style
}
return rail[cell], style.Dim(true)
})
return row.New(text.New(symbol), waveform, text.New(label)).Spacing(1)
}
func (m Model) drawEmbeds(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int, contentRoot ast.Node, contentSource []byte) {
if len(message.Embeds) == 0 {
return
@ -855,6 +958,12 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
return m, m.open()
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.OpenInBrowser.Keybind):
return m, m.openInBrowser()
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.Play.Keybind):
return m, m.play()
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.SeekBackward.Keybind):
return m, m.seek(m.voiceStatus.Position - seekStep)
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.SeekForward.Keybind):
return m, m.seek(m.voiceStatus.Position + seekStep)
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.Download.Keybind):
return m, m.download()
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.Reply.Keybind):
@ -903,6 +1012,17 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
// Invalidate the view so it is laid out with the preview.
m.items[index].view = nil
return m, cmd
case seekMsg:
return m, m.seek(time.Duration(msg))
case voiceMsg:
m.voiceStatus = m.voicePlayer.Status()
for i := range m.items {
if isVoiceMessage(m.items[i].message) {
m.items[i].view = nil
}
}
return m, m.Init()
case tview.ResizeMsg:
cols, rows := msg.Size()
width, height := msg.PixelSize()
@ -1034,15 +1154,40 @@ func messageURLs(msg discord.Message) []string {
return urls
}
func downloadAttachment(attachment discord.Attachment, destination string) error {
resp, err := http.Get(attachment.URL)
// get requests the file at url, by a new link to it if that one expired.
func (m Model) get(url string) (*http.Response, error) {
resp, err := http.Get(url)
if err != nil || resp.StatusCode == http.StatusOK {
return resp, err
}
resp.Body.Close()
var links struct {
URLs []struct {
Refreshed string `json:"refreshed"`
} `json:"refreshed_urls"`
}
body := map[string][]string{"attachment_urls": {url}}
if err := m.state.RequestJSONInto(&links, http.MethodPost, api.Endpoint+"attachments/refresh-urls", httputil.WithJSONBody(body)); err != nil {
return nil, err
}
if len(links.URLs) != 0 {
if resp, err = http.Get(links.URLs[0].Refreshed); err != nil || resp.StatusCode == http.StatusOK {
return resp, err
}
resp.Body.Close()
}
return nil, fmt.Errorf("unexpected HTTP status: %s", resp.Status)
}
func (m Model) downloadAttachment(attachment discord.Attachment, destination string) error {
resp, err := m.get(attachment.URL)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
}
file, err := os.Create(destination)
if err != nil {
return err

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/ayn2op/arikawa/v3/discord"
@ -180,3 +181,12 @@ func TestModelSelectLastOwn(t *testing.T) {
})
}
}
func TestWaveformRail(t *testing.T) {
waveform := make([]byte, 256)
waveform[255] = 255
if got, want := strings.Join(waveformRail(waveform), ""), strings.Repeat("▁", 28)+"█"; got != want {
t.Errorf("got %q, want %q", got, want)
}
}

View file

@ -3,11 +3,13 @@ package messageslist
import (
"context"
"errors"
"io"
"log/slog"
"mime"
"os"
"path/filepath"
"slices"
"time"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/gateway"
@ -32,6 +34,19 @@ type olderMessagesLoadedMsg struct {
func (olderMessagesLoadedMsg) messagesList() {}
// voiceMsg reports that the status of the voice message player changed.
type voiceMsg struct{}
func (voiceMsg) messagesList() {}
// Init waits for that.
func (m Model) Init() tview.Cmd {
return func() tview.Msg {
<-m.voicePlayer.Updates()
return voiceMsg{}
}
}
// ReplyMsg asks to reply to Message by Name, mentioning its author if Mention is set.
type ReplyMsg struct {
Message discord.Message
@ -140,6 +155,63 @@ func (m Model) open() tview.Cmd {
return m.openWith(m.openAttachment)
}
func isVoiceMessage(message discord.Message) bool {
return message.Flags&discord.IsVoiceMessage != 0 && len(message.Attachments) == 1
}
// play plays the selected voice message, or pauses or resumes it if it is the one playing.
func (m Model) play() tview.Cmd {
message, ok := m.selectedMessage()
if !ok || !isVoiceMessage(*message) {
return nil
}
attachment := message.Attachments[0]
return func() tview.Msg {
if err := m.playVoiceMessage(attachment); err != nil {
return ui.ErrorDialog("play voice message", err)
}
return nil
}
}
func (m Model) playVoiceMessage(attachment discord.Attachment) error {
if toggled, err := m.voicePlayer.Toggle(attachment.ID); toggled || err != nil {
return err
}
resp, err := m.get(attachment.URL)
if err != nil {
return err
}
defer resp.Body.Close()
// It is read whole, for playing it not to wait for the network.
audio, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
return m.voicePlayer.Play(attachment.ID, audio)
}
// seekMsg asks to seek to where the waveform of a voice message was clicked.
type seekMsg time.Duration
func (seekMsg) messagesList() {}
// seek moves the voice message that is playing or paused to position.
func (m Model) seek(position time.Duration) tview.Cmd {
return func() tview.Msg {
if err := m.voicePlayer.Seek(position); err != nil {
return ui.ErrorDialog("seek voice message", err)
}
return nil
}
}
func (m Model) openInBrowser() tview.Cmd {
return m.openWith(func(attachment discord.Attachment) tview.Cmd { return openURL(attachment.URL) })
}
@ -170,12 +242,12 @@ func (m Model) download() tview.Cmd {
}
if len(selectedMessage.Attachments) == 1 {
attachment := selectedMessage.Attachments[0]
return m.confirmAttachment(attachment, saveAttachment(attachment))
return m.confirmAttachment(attachment, m.saveAttachment(attachment))
}
items := make(picker.Items, len(selectedMessage.Attachments))
for i, attachment := range selectedMessage.Attachments {
items[i] = picker.Item{Text: attachment.Filename, Reference: m.confirmAttachment(attachment, saveAttachment(attachment))}
items[i] = picker.Item{Text: attachment.Filename, Reference: m.confirmAttachment(attachment, m.saveAttachment(attachment))}
}
return m.showAttachmentsPicker(items)
}
@ -196,7 +268,7 @@ func (m Model) showAttachmentsPicker(items picker.Items) tview.Cmd {
}
func (m Model) openAttachment(attachment discord.Attachment) tview.Cmd {
return m.confirmAttachment(attachment, openDownloadedAttachment(attachment))
return m.confirmAttachment(attachment, m.openDownloadedAttachment(attachment))
}
func (m Model) confirmAttachment(attachment discord.Attachment, action tview.Cmd) tview.Cmd {
@ -210,7 +282,7 @@ func (m Model) confirmAttachment(attachment discord.Attachment, action tview.Cmd
return action
}
func openDownloadedAttachment(attachment discord.Attachment) tview.Cmd {
func (m Model) openDownloadedAttachment(attachment discord.Attachment) tview.Cmd {
return func() tview.Msg {
extension := filepath.Ext(attachment.Filename)
if extension == "" {
@ -233,7 +305,7 @@ func openDownloadedAttachment(attachment discord.Attachment) tview.Cmd {
path := file.Name()
if err := downloadAttachment(attachment, path); err != nil {
if err := m.downloadAttachment(attachment, path); err != nil {
os.Remove(path)
return ui.ErrorDialog("download attachment", err)
} else if err := open.Start(path); err != nil {
@ -244,7 +316,7 @@ func openDownloadedAttachment(attachment discord.Attachment) tview.Cmd {
}
}
func saveAttachment(attachment discord.Attachment) tview.Cmd {
func (m Model) saveAttachment(attachment discord.Attachment) tview.Cmd {
return func() tview.Msg {
destination, err := zenity.SelectFileSave(zenity.Filename(filepath.Base(attachment.Filename)), zenity.ConfirmOverwrite())
if errors.Is(err, zenity.ErrCanceled) {
@ -254,7 +326,7 @@ func saveAttachment(attachment discord.Attachment) tview.Cmd {
return ui.ErrorDialog("select attachment destination", err)
}
if err := downloadAttachment(attachment, destination); err != nil {
if err := m.downloadAttachment(attachment, destination); err != nil {
return ui.ErrorDialog("download attachment", err)
}
return nil

View file

@ -6,7 +6,6 @@ import (
_ "image/jpeg"
_ "image/png"
"log/slog"
"net/http"
"net/url"
"slices"
"strconv"
@ -96,16 +95,16 @@ func (m Model) loadPreviews() tview.Cmd {
}
// Reserve the preview's rows with a blank image of the same size, so the messages below do not shift when it loads.
item.previews[s.proxy] = widget(image.NewAlpha(bounds))
cmds = append(cmds, loadPreview(item.message.ID, s.proxy, bounds, widget))
cmds = append(cmds, m.loadPreview(item.message.ID, s.proxy, bounds, widget))
}
}
return tview.Batch(cmds...)
}
// loadPreview yields an empty preview if loading fails.
func loadPreview(messageID discord.MessageID, proxy discord.URL, bounds image.Rectangle, widget func(image.Image) tviewimage.Widget) tview.Cmd {
func (m Model) loadPreview(messageID discord.MessageID, proxy discord.URL, bounds image.Rectangle, widget func(image.Image) tviewimage.Widget) tview.Cmd {
return func() tview.Msg {
img, err := downloadPreview(proxy, bounds)
img, err := m.downloadPreview(proxy, bounds)
if err != nil {
slog.Error("failed to load preview", "err", err)
img = &image.Alpha{}
@ -114,7 +113,7 @@ func loadPreview(messageID discord.MessageID, proxy discord.URL, bounds image.Re
}
}
func downloadPreview(proxy discord.URL, bounds image.Rectangle) (image.Image, error) {
func (m Model) downloadPreview(proxy discord.URL, bounds image.Rectangle) (image.Image, error) {
u, err := url.Parse(proxy)
if err != nil {
return nil, err
@ -124,7 +123,7 @@ func downloadPreview(proxy discord.URL, bounds image.Rectangle) (image.Image, er
query.Set("height", strconv.Itoa(bounds.Dy()))
u.RawQuery = query.Encode()
resp, err := http.Get(u.String())
resp, err := m.get(u.String())
if err != nil {
return nil, err
}

View file

@ -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), m.guildsTree.Init(), tview.RequestTerminalInfo())
return tview.Batch(openState(m.state), listen(m.events), m.guildsTree.Init(), m.messagesList.Init(), tview.RequestTerminalInfo())
}
// Update returns m changed in response to msg and a command to run, or nil.

201
internal/voice/player.go Normal file
View file

@ -0,0 +1,201 @@
//go:build !no_voice
package voice
import (
"bytes"
"encoding/binary"
"io"
"sync"
"time"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/gen2brain/malgo"
"github.com/tphakala/go-opus/oggopus"
)
const (
// The sizes of the header of an Ogg page, and where in it its flags, its granule position and how many segments it has are.
oggHeaderSize = 27
oggFlagsOffset = 5
oggGranuleOffset = 6
oggSegmentsOffset = 26
// oggContinued is the flag of a page that begins with the rest of a packet.
oggContinued = 1
// progressSamples is how many samples are played between updates.
progressSamples = sampleRate / 10
)
// Player plays voice messages, one at a time.
type Player struct {
updates chan struct{}
mu sync.Mutex
// device plays the voice message that is the attachment id, and is nil once it ended.
device *malgo.Device
id discord.AttachmentID
// audio is the Ogg Opus file, decoder what is left of it to play, and samples how much of it was played.
audio []byte
decoder *oggopus.Decoder
samples int64
}
func NewPlayer() *Player {
return &Player{updates: make(chan struct{}, 1)}
}
// Updates is sent to when the status changes.
func (p *Player) Updates() <-chan struct{} {
return p.updates
}
func (p *Player) update() {
select {
case p.updates <- struct{}{}:
default:
}
}
func (p *Player) Status() PlayerStatus {
p.mu.Lock()
defer p.mu.Unlock()
if p.device == nil {
return PlayerStatus{}
}
return PlayerStatus{ID: p.id, Position: time.Duration(p.samples) * time.Second / sampleRate, Playing: p.device.IsStarted()}
}
// Toggle pauses or resumes the voice message that is the attachment id, and reports whether it is the one that Play was last given and has not ended.
func (p *Player) Toggle(id discord.AttachmentID) (bool, error) {
p.mu.Lock()
defer p.mu.Unlock()
defer p.update()
switch {
case p.device == nil || p.id != id:
return false, nil
case p.device.IsStarted():
return true, p.device.Stop()
default:
return true, p.device.Start()
}
}
// stop stops device if it is what is playing.
func (p *Player) stop(device *malgo.Device) {
if device != nil && p.device == device {
device.Uninit()
p.device = nil
p.update()
}
}
// seek makes what is left to play start at position, by decoding from the last page that ends before it, as the decoder cannot seek.
func (p *Player) seek(position time.Duration) error {
sample := int64(max(position, 0) * sampleRate / time.Second)
// headers is where the pages before the audio end, start the page to decode from, and first the sample it starts with.
var headers, start int
var first, last int64
for offset := 0; offset+oggHeaderSize <= len(p.audio); {
page := p.audio[offset:]
segments := page[oggHeaderSize:][:min(int(page[oggSegmentsOffset]), len(page)-oggHeaderSize)]
size := oggHeaderSize + len(segments)
for _, segment := range segments {
size += int(segment)
}
granule := int64(binary.LittleEndian.Uint64(page[oggGranuleOffset:]))
switch {
case granule == 0:
headers, start = offset+size, offset+size
case last <= sample && page[oggFlagsOffset]&oggContinued == 0:
start, first = offset, last
}
// A page that no packet ends on has no position.
if granule > 0 {
last = granule
}
offset += size
}
decoder, err := oggopus.NewDecoder(io.MultiReader(bytes.NewReader(p.audio[:headers]), bytes.NewReader(p.audio[min(start, len(p.audio)):])))
if err != nil {
return err
}
// Nothing is left to play if position is past the end.
io.CopyN(io.Discard, decoder, (sample-first)*int64(decoder.Info().Channels)*2)
p.decoder, p.samples = decoder, sample
return nil
}
// Seek moves the voice message that is playing or paused to position.
func (p *Player) Seek(position time.Duration) error {
p.mu.Lock()
defer p.mu.Unlock()
defer p.update()
if p.device == nil {
return nil
}
return p.seek(position)
}
// Play plays the voice message that is the attachment id, which is the Ogg Opus file audio, in place of the one that is playing.
func (p *Player) Play(id discord.AttachmentID, audio []byte) error {
p.mu.Lock()
defer p.mu.Unlock()
p.stop(p.device)
p.audio = audio
if err := p.seek(0); err != nil {
return err
}
var device *malgo.Device
ended := sync.OnceFunc(func() {
p.mu.Lock()
defer p.mu.Unlock()
p.stop(device)
})
channels := p.decoder.Info().Channels
device, err := startDevice(malgo.Playback, channels, func(output, _ []byte) {
// What is played is left silent rather than wait for Seek, and for Play, which has not set the device yet.
if !p.mu.TryLock() {
return
}
defer p.mu.Unlock()
if p.device != device {
return
}
n, err := io.ReadFull(p.decoder, output)
played := p.samples + int64(n/channels/2)
if played/progressSamples != p.samples/progressSamples {
p.update()
}
p.samples = played
// A device cannot be stopped by what it calls.
if err != nil {
go ended()
}
})
p.device, p.id = device, id
p.update()
return err
}

View file

@ -0,0 +1,39 @@
//go:build !no_voice
package voice
import (
"bytes"
"io"
"testing"
"time"
"github.com/tphakala/go-opus/oggopus"
)
func TestPlayerSeek(t *testing.T) {
const seconds = 10
var audio bytes.Buffer
if err := oggopus.EncodeInterleaved(&audio, oggopus.Config{SampleRate: sampleRate, Channels: 1}, make([]byte, seconds*sampleRate*2)); err != nil {
t.Fatal(err)
}
p := Player{audio: audio.Bytes()}
for _, position := range []time.Duration{0, 500 * time.Millisecond, 6 * time.Second, seconds * time.Second, time.Hour} {
if err := p.seek(position); err != nil {
t.Fatal(err)
}
left, err := io.Copy(io.Discard, p.decoder)
if err != nil {
t.Fatal(err)
}
// What is left is a packet longer at most, as the end is only trimmed when played from the start.
want := max(seconds*time.Second-position, 0)
if got := time.Duration(left/2) * time.Second / sampleRate; got < want || got > want+20*time.Millisecond {
t.Errorf("seek to %v: %v is left, want %v", position, got, want)
}
}
}

View file

@ -42,7 +42,7 @@ var audio = sync.OnceValues(func() (*malgo.AllocatedContext, error) {
})
// 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) {
func startDevice(kind malgo.DeviceType, channels int, data func(output, input []byte)) (*malgo.Device, error) {
ctx, err := audio()
if err != nil {
return nil, err
@ -51,8 +51,8 @@ func startDevice(kind malgo.DeviceType, data func(output, input []byte)) (*malgo
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
config.Playback.Format, config.Playback.Channels = malgo.FormatS16, uint32(channels)
config.Capture.Format, config.Capture.Channels = malgo.FormatS16, uint32(channels)
device, err := malgo.InitDevice(ctx.Context, config, malgo.DeviceCallbacks{
Data: func(output, input []byte, _ uint32) { data(output, input) },
@ -192,7 +192,7 @@ func (s *Session) setSpeaking(userID discord.UserID, speaking bool) {
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)
*device, err = startDevice(kind, channels, data)
case !on && *device != nil:
(*device).Uninit()
*device = nil

View file

@ -22,6 +22,17 @@ 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")
}
var errNoVoice = errors.New("built without voice (build tag: no_voice)")
func (*Session) Join(discord.ChannelID) error { return errNoVoice }
// Player plays nothing.
type Player struct{}
func NewPlayer() *Player { return new(Player) }
func (*Player) Updates() <-chan struct{} { return nil }
func (*Player) Status() PlayerStatus { return PlayerStatus{} }
func (*Player) Toggle(discord.AttachmentID) (bool, error) { return false, nil }
func (*Player) Play(discord.AttachmentID, []byte) error { return errNoVoice }
func (*Player) Seek(time.Duration) error { return nil }

View file

@ -1,7 +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 joins voice channels, plays what is said in them, sends what the user says, and plays voice messages. 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"
import (
"time"
"github.com/ayn2op/arikawa/v3/discord"
)
// Status is what a Session is doing.
type Status struct {
@ -9,3 +13,10 @@ type Status struct {
ChannelID discord.ChannelID
Joined bool
}
// PlayerStatus is the voice message a Player is playing or has paused, if ID is valid.
type PlayerStatus struct {
ID discord.AttachmentID
Position time.Duration
Playing bool
}