264 lines
5.3 KiB
Go
264 lines
5.3 KiB
Go
package recording
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/barnard/gumble/gumble"
|
|
)
|
|
|
|
const (
|
|
FormatFLAC = "flac"
|
|
FormatOpus = "opus"
|
|
)
|
|
|
|
type Recorder struct {
|
|
path string
|
|
format string
|
|
frameSize int
|
|
interval time.Duration
|
|
|
|
cmd *exec.Cmd
|
|
stdin io.WriteCloser
|
|
|
|
input chan sourceFrame
|
|
stop chan struct{}
|
|
done chan struct{}
|
|
once sync.Once
|
|
|
|
mu sync.Mutex
|
|
err error
|
|
}
|
|
|
|
type sourceFrame struct {
|
|
source uint32
|
|
samples []int16
|
|
}
|
|
|
|
func New(directory string, format string, now time.Time, frameSize int, interval time.Duration) (*Recorder, error) {
|
|
format = NormalizeFormat(format)
|
|
if format != FormatFLAC && format != FormatOpus {
|
|
return nil, fmt.Errorf("unsupported recording format %q", format)
|
|
}
|
|
if frameSize <= 0 {
|
|
return nil, errors.New("invalid recording frame size")
|
|
}
|
|
if interval <= 0 {
|
|
interval = gumble.AudioDefaultInterval
|
|
}
|
|
if err := os.MkdirAll(directory, 0755); err != nil {
|
|
return nil, err
|
|
}
|
|
path := UniquePath(directory, now, format)
|
|
args := ffmpegArgs(format, path)
|
|
cmd := exec.Command("ffmpeg", args...)
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, err
|
|
}
|
|
recorder := &Recorder{
|
|
path: path,
|
|
format: format,
|
|
frameSize: frameSize,
|
|
interval: interval,
|
|
cmd: cmd,
|
|
stdin: stdin,
|
|
input: make(chan sourceFrame, 512),
|
|
stop: make(chan struct{}),
|
|
done: make(chan struct{}),
|
|
}
|
|
go recorder.run()
|
|
return recorder, nil
|
|
}
|
|
|
|
func NormalizeFormat(format string) string {
|
|
format = strings.ToLower(strings.TrimSpace(format))
|
|
format = strings.TrimPrefix(format, ".")
|
|
if format == "" {
|
|
return FormatFLAC
|
|
}
|
|
return format
|
|
}
|
|
|
|
func UniquePath(directory string, now time.Time, format string) string {
|
|
base := fmt.Sprintf("barnard-recording-%s", now.Format("20060102-150405"))
|
|
path := filepath.Join(directory, base+"."+format)
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
return path
|
|
}
|
|
for i := 2; ; i++ {
|
|
path = filepath.Join(directory, fmt.Sprintf("%s-%d.%s", base, i, format))
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
return path
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) Path() string {
|
|
return r.path
|
|
}
|
|
|
|
func (r *Recorder) RecordAudioFrame(source uint32, samples []int16) {
|
|
if r == nil || len(samples) == 0 {
|
|
return
|
|
}
|
|
if len(r.input) >= cap(r.input) {
|
|
return
|
|
}
|
|
frame := NormalizeStereoFrame(samples, r.frameSize)
|
|
select {
|
|
case r.input <- sourceFrame{source: source, samples: frame}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) Stop() error {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
r.once.Do(func() {
|
|
close(r.stop)
|
|
if r.stdin != nil {
|
|
r.stdin.Close()
|
|
}
|
|
})
|
|
select {
|
|
case <-r.done:
|
|
case <-time.After(2 * time.Second):
|
|
if r.cmd != nil && r.cmd.Process != nil {
|
|
r.cmd.Process.Kill()
|
|
}
|
|
<-r.done
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.err
|
|
}
|
|
|
|
func (r *Recorder) run() {
|
|
defer close(r.done)
|
|
ticker := time.NewTicker(r.interval)
|
|
defer ticker.Stop()
|
|
queues := make(map[uint32][][]int16)
|
|
frame := make([]int16, r.frameSize*gumble.AudioChannels)
|
|
for {
|
|
select {
|
|
case <-r.stop:
|
|
r.closeEncoder()
|
|
return
|
|
case item := <-r.input:
|
|
queues[item.source] = append(queues[item.source], item.samples)
|
|
case <-ticker.C:
|
|
clear(frame)
|
|
for source, queue := range queues {
|
|
if len(queue) == 0 {
|
|
delete(queues, source)
|
|
continue
|
|
}
|
|
mix(frame, queue[0])
|
|
queue = queue[1:]
|
|
if len(queue) == 0 {
|
|
delete(queues, source)
|
|
} else {
|
|
queues[source] = queue
|
|
}
|
|
}
|
|
if err := writePCM(r.stdin, frame); err != nil {
|
|
r.setError(err)
|
|
r.closeEncoder()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) closeEncoder() {
|
|
if r.stdin != nil {
|
|
if err := r.stdin.Close(); err != nil {
|
|
if !errors.Is(err, os.ErrClosed) {
|
|
r.setError(err)
|
|
}
|
|
}
|
|
}
|
|
if r.cmd != nil {
|
|
if err := r.cmd.Wait(); err != nil {
|
|
r.setError(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) setError(err error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.err == nil {
|
|
r.err = err
|
|
}
|
|
}
|
|
|
|
func NormalizeStereoFrame(samples []int16, frameSize int) []int16 {
|
|
out := make([]int16, frameSize*gumble.AudioChannels)
|
|
if len(samples) >= frameSize*gumble.AudioChannels && len(samples)%gumble.AudioChannels == 0 {
|
|
copy(out, samples[:frameSize*gumble.AudioChannels])
|
|
return out
|
|
}
|
|
limit := frameSize
|
|
if len(samples) < limit {
|
|
limit = len(samples)
|
|
}
|
|
for i := 0; i < limit; i++ {
|
|
out[i*2] = samples[i]
|
|
out[i*2+1] = samples[i]
|
|
}
|
|
return out
|
|
}
|
|
|
|
func mix(dst []int16, src []int16) {
|
|
limit := len(dst)
|
|
if len(src) < limit {
|
|
limit = len(src)
|
|
}
|
|
for i := 0; i < limit; i++ {
|
|
sum := int32(dst[i]) + int32(src[i])
|
|
if sum > 32767 {
|
|
sum = 32767
|
|
} else if sum < -32768 {
|
|
sum = -32768
|
|
}
|
|
dst[i] = int16(sum)
|
|
}
|
|
}
|
|
|
|
func writePCM(w io.Writer, samples []int16) error {
|
|
buf := make([]byte, len(samples)*2)
|
|
for i, sample := range samples {
|
|
binary.LittleEndian.PutUint16(buf[i*2:], uint16(sample))
|
|
}
|
|
_, err := w.Write(buf)
|
|
return err
|
|
}
|
|
|
|
func ffmpegArgs(format string, path string) []string {
|
|
args := []string{
|
|
"-loglevel", "error",
|
|
"-f", "s16le",
|
|
"-ar", fmt.Sprintf("%d", gumble.AudioSampleRate),
|
|
"-ac", fmt.Sprintf("%d", gumble.AudioChannels),
|
|
"-i", "pipe:0",
|
|
}
|
|
if format == FormatOpus {
|
|
args = append(args, "-c:a", "libopus")
|
|
}
|
|
return append(args, "-y", path)
|
|
}
|