Add standards-aware recording
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user