168 lines
4.1 KiB
Go
168 lines
4.1 KiB
Go
package audio
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"fmt"
|
|
)
|
|
|
|
const (
|
|
BufferLength = 300 // 5min / 3.6MB
|
|
Channels = 1 // unfortunately, Discord doesn't seem to support stereo at the time
|
|
BitRate = 96000
|
|
SampleRate = 48000
|
|
FrameSize = 960 // 960 samples
|
|
FrameDuration = float64(FrameSize) / float64(SampleRate) // 20ms
|
|
FramesPerSecond = SampleRate / FrameSize
|
|
)
|
|
|
|
var (
|
|
ErrNotHttp = errors.New("the requested resource is not an http/https address")
|
|
)
|
|
|
|
type frameIdx struct {
|
|
start uint
|
|
end uint
|
|
}
|
|
|
|
// Takes a file path/HTTP(S) stream URL and returns Discord audio frames through
|
|
// audioFrameCh. After audioFrameCh is closed, errCh can be read to get any
|
|
// potential error. Will cleanly kill ffmpeg if a struct{} is sent through
|
|
// killCh (IMPORTANT: only send the kill signal ONCE: there is a chance that
|
|
// this goroutine exits just before you send a kill signal; this will be
|
|
// absorbed by the channel buffer, but your program might get stuck if you try
|
|
// to send two kill signals to a dead stream).
|
|
func StreamToDiscordOpus(ffmpegPath, input string, stdin io.Reader, seekSeconds float64, playbackSpeed float64, inetOnly bool) (audioFrameCh <-chan []byte, errCh <-chan error, killCh chan<- struct{}) {
|
|
out := make(chan []byte, BufferLength*FramesPerSecond)
|
|
errch := make(chan error, 1)
|
|
killch := make(chan struct{}, 1)
|
|
|
|
go func() {
|
|
defer close(out)
|
|
defer close(errch)
|
|
|
|
if inetOnly && !(strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://")) {
|
|
errch <- ErrNotHttp
|
|
return
|
|
}
|
|
|
|
// Set ffmpeg options
|
|
var cmdOpts []string
|
|
cmdOpts = append(cmdOpts,
|
|
"-vn", // no video
|
|
"-sn", // no subs
|
|
"-dn") // no data encoding
|
|
if seekSeconds != 0.0 {
|
|
cmdOpts = append(cmdOpts,
|
|
"-accurate_seek",
|
|
"-ss", strconv.FormatFloat(seekSeconds, 'f', 5, 64)) // seek duration
|
|
}
|
|
cmdOpts = append(cmdOpts,
|
|
"-i", input)
|
|
if playbackSpeed != 1.0 {
|
|
cmdOpts = append(cmdOpts,
|
|
"-filter:a", "atempo="+strconv.FormatFloat(playbackSpeed, 'f', 5, 64)) // playback speed
|
|
}
|
|
cmdOpts = append(cmdOpts,
|
|
"-ab", strconv.Itoa(BitRate), // audio bit rate
|
|
"-ac", strconv.Itoa(Channels), // audio channels
|
|
"-frame_size", strconv.Itoa(int(FrameDuration*1000)), // frame size (in ms)
|
|
"-f", "opus", // output OPUS audio
|
|
"pipe:1") // output to stdout
|
|
|
|
// Prepare ffmpeg command
|
|
cmd := exec.Command(ffmpegPath, cmdOpts...)
|
|
if stdin != nil {
|
|
cmd.Stdin = stdin
|
|
}
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
errch <- err
|
|
return
|
|
}
|
|
|
|
// Start ffmpeg
|
|
if err := cmd.Start(); err != nil {
|
|
errch <- err
|
|
return
|
|
}
|
|
|
|
// We want to let our main loop know if ffmpeg is done
|
|
donech := make(chan error)
|
|
go func() {
|
|
donech <- cmd.Wait()
|
|
}()
|
|
|
|
// Ogg decoder
|
|
dec := newOggDecoder(stdout)
|
|
var segDec *oggSegmentDecoder
|
|
startSegDec := false
|
|
|
|
// Avoid dropping frames
|
|
wantNewFrame := true
|
|
|
|
// Main opus encoder loop
|
|
for {
|
|
var frame []byte
|
|
|
|
for wantNewFrame {
|
|
if startSegDec && segDec.More() {
|
|
frame = make([]byte, segDec.SegmentSize())
|
|
if err := segDec.ReadSegment(frame); err != nil {
|
|
errch <- err
|
|
return
|
|
}
|
|
wantNewFrame = false
|
|
} else if dec.More() {
|
|
var err error
|
|
var hdr oggPageHeader
|
|
hdr, segDec, err = dec.Page()
|
|
if err != nil {
|
|
errch <- err
|
|
return
|
|
}
|
|
if hdr.GranulePosition != 0 {
|
|
startSegDec = true
|
|
}
|
|
} else {
|
|
out = nil
|
|
break
|
|
}
|
|
}
|
|
|
|
// Channel IO
|
|
select {
|
|
case err := <-donech:
|
|
fmt.Println("Audio done, waiting for read to finish")
|
|
if err != nil {
|
|
// Send error and exit
|
|
errch <- err
|
|
return
|
|
}
|
|
// Process exited normally, wait until all samples are read
|
|
// before closing the channels
|
|
for len(out) != 0 {
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
fmt.Println("Audio read finished")
|
|
return
|
|
case <-killch:
|
|
// Process was killed by user
|
|
cmd.Process.Signal(os.Interrupt)
|
|
return
|
|
case out <- frame:
|
|
// Output is ready to receive a new frame
|
|
wantNewFrame = true
|
|
}
|
|
}
|
|
}()
|
|
|
|
return out, errch, killch
|
|
}
|