211 lines
5.2 KiB
Go
211 lines
5.2 KiB
Go
//go:build !no_voice
|
|
|
|
package voice
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"errors"
|
|
"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:]
|
|
count := min(int(page[oggSegmentsOffset]), len(page)-oggHeaderSize)
|
|
segments := page[oggHeaderSize:][:count]
|
|
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
|
|
}
|
|
|
|
// The decoder needs the headers, and then the pages from start on.
|
|
end := len(p.audio)
|
|
header := bytes.NewReader(p.audio[:min(headers, end)])
|
|
rest := bytes.NewReader(p.audio[min(start, end):])
|
|
decoder, err := oggopus.NewDecoder(io.MultiReader(header, rest))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// The samples between first and position are decoded and dropped.
|
|
skip := (sample - first) * int64(decoder.Info().Channels) * sampleBytes
|
|
// Nothing is left to play if position is past the end.
|
|
if _, err := io.CopyN(io.Discard, decoder, skip); err != nil && !errors.Is(err, io.EOF) {
|
|
return err
|
|
}
|
|
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
|
|
}
|