Fix recording truncation and rate mismatch causing static

The recording pipeline had two bugs that combined to produce
buzzing/static in recorded audio:

1. NormalizeStereoFrame truncated incoming audio frames to
   frameSize*AudioChannels samples. When the Opus decoder produced
   20ms frames (1920 stereo samples) but the recorder used a 10ms
   frameSize (960 samples), half the audio from every packet was
   silently dropped.

2. The recorder's run() loop dequeued one fixed-length frame per
   source per tick. After truncation, the remaining 10ms of each
   20ms packet was gone, so every other tick produced silence.
   This 50 Hz on/off pattern sounded like static.

Fixes:
- NormalizeStereoFrame no longer truncates; it only converts mono
  to stereo and preserves all audio data
- RecordAudioFrame now accepts an explicit stereo flag from callers
  instead of guessing from sample count (which failed for even-length
  mono data like 480-sample mic frames)
- run() accumulates variable-length frames per source and consumes
  them in fixed-size chunks, preserving any leftover for the next tick
This commit is contained in:
Brandon McGinty (deepseek)
2026-08-10 18:34:52 -04:00
committed by Brandon McGinty
parent cd003d7ac4
commit 90f8a1ca5f
3 changed files with 57 additions and 33 deletions
+4 -4
View File
@@ -45,7 +45,7 @@ type FilePlayer interface {
} }
type Recorder interface { type Recorder interface {
RecordAudioFrame(source uint32, samples []int16) RecordAudioFrame(source uint32, samples []int16, stereo bool)
} }
const recorderOutgoingSource uint32 = ^uint32(0) const recorderOutgoingSource uint32 = ^uint32(0)
@@ -742,7 +742,7 @@ func (s *Stream) processAudioPacket(packet *gumble.AudioPacket, user *gumble.Use
} }
} }
if recorder != nil && recordPtr > 0 { if recorder != nil && recordPtr > 0 {
recorder.RecordAudioFrame(user.Session, recordBuffer[:recordPtr]) recorder.RecordAudioFrame(user.Session, recordBuffer[:recordPtr], true)
} }
if len(emptyBufs) == 0 { if len(emptyBufs) == 0 {
@@ -932,7 +932,7 @@ func (s *Stream) sourceRoutine(inputDevice *string, stop chan bool, done chan st
// Send stereo buffer when file is playing // Send stereo buffer when file is playing
outgoing <- gumble.AudioBuffer(outputBuffer) outgoing <- gumble.AudioBuffer(outputBuffer)
if recorder := s.getRecorder(); recorder != nil { if recorder := s.getRecorder(); recorder != nil {
recorder.RecordAudioFrame(recorderOutgoingSource, outputBuffer) recorder.RecordAudioFrame(recorderOutgoingSource, outputBuffer, true)
} }
} else if hasMicInput { } else if hasMicInput {
// Send mic when no file is playing. If the microphone is // Send mic when no file is playing. If the microphone is
@@ -949,7 +949,7 @@ func (s *Stream) sourceRoutine(inputDevice *string, stop chan bool, done chan st
} }
outgoing <- gumble.AudioBuffer(outBuf) outgoing <- gumble.AudioBuffer(outBuf)
if recorder := s.getRecorder(); recorder != nil { if recorder := s.getRecorder(); recorder != nil {
recorder.RecordAudioFrame(recorderOutgoingSource, outBuf) recorder.RecordAudioFrame(recorderOutgoingSource, outBuf, false)
} }
} }
} }
+29 -25
View File
@@ -152,14 +152,14 @@ func (r *Recorder) Path() string {
return r.path return r.path
} }
func (r *Recorder) RecordAudioFrame(source uint32, samples []int16) { func (r *Recorder) RecordAudioFrame(source uint32, samples []int16, stereo bool) {
if r == nil || len(samples) == 0 { if r == nil || len(samples) == 0 {
return return
} }
if len(r.input) >= cap(r.input) { if len(r.input) >= cap(r.input) {
return return
} }
frame := NormalizeStereoFrame(samples, r.frameSize) frame := NormalizeStereoFrame(samples, stereo)
select { select {
case r.input <- sourceFrame{source: source, samples: frame}: case r.input <- sourceFrame{source: source, samples: frame}:
default: default:
@@ -192,31 +192,35 @@ func (r *Recorder) run() {
defer close(r.done) defer close(r.done)
ticker := time.NewTicker(r.interval) ticker := time.NewTicker(r.interval)
defer ticker.Stop() defer ticker.Stop()
queues := make(map[uint32][][]int16) // Per-source accumulated stereo samples. Incoming frames of any size are
frame := make([]int16, r.frameSize*gumble.AudioChannels) // appended and then consumed in frameSize*AudioChannels chunks each tick.
queues := make(map[uint32][]int16)
chunkSize := r.frameSize * gumble.AudioChannels
chunk := make([]int16, chunkSize)
for { for {
select { select {
case <-r.stop: case <-r.stop:
r.closeEncoder() r.closeEncoder()
return return
case item := <-r.input: case item := <-r.input:
queues[item.source] = append(queues[item.source], item.samples) queues[item.source] = append(queues[item.source], item.samples...)
case <-ticker.C: case <-ticker.C:
clear(frame) clear(chunk)
for source, queue := range queues { for source, buffer := range queues {
if len(queue) == 0 { if len(buffer) == 0 {
delete(queues, source) delete(queues, source)
continue continue
} }
mix(frame, queue[0]) // Mix one chunk worth of samples from this source.
queue = queue[1:] if len(buffer) <= chunkSize {
if len(queue) == 0 { mix(chunk, buffer)
delete(queues, source) delete(queues, source)
} else { } else {
queues[source] = queue mix(chunk, buffer[:chunkSize])
queues[source] = buffer[chunkSize:]
} }
} }
if err := writePCM(r.stdin, frame); err != nil { if err := writePCM(r.stdin, chunk); err != nil {
r.setError(err) r.setError(err)
r.closeEncoder() r.closeEncoder()
return return
@@ -248,19 +252,19 @@ func (r *Recorder) setError(err error) {
} }
} }
func NormalizeStereoFrame(samples []int16, frameSize int) []int16 { // NormalizeStereoFrame ensures samples are in stereo interleaved format.
out := make([]int16, frameSize*gumble.AudioChannels) // If stereo is true the samples are returned as-is (already interleaved).
if len(samples) >= frameSize*gumble.AudioChannels && len(samples)%gumble.AudioChannels == 0 { // Mono input is duplicated to both channels. The returned slice preserves
copy(out, samples[:frameSize*gumble.AudioChannels]) // all input audio without truncation.
return out func NormalizeStereoFrame(samples []int16, stereo bool) []int16 {
if stereo {
return samples
} }
limit := frameSize // Convert mono to stereo by duplicating each sample.
if len(samples) < limit { out := make([]int16, len(samples)*gumble.AudioChannels)
limit = len(samples) for i, s := range samples {
} out[i*2] = s
for i := 0; i < limit; i++ { out[i*2+1] = s
out[i*2] = samples[i]
out[i*2+1] = samples[i]
} }
return out return out
} }
+24 -4
View File
@@ -93,16 +93,36 @@ func TestReservePathPreventsConcurrentRecordingCollisions(t *testing.T) {
} }
func TestNormalizeStereoFrame(t *testing.T) { func TestNormalizeStereoFrame(t *testing.T) {
mono := NormalizeStereoFrame([]int16{1, -2}, 3) // Mono input duplicating each sample to both channels.
wantMono := []int16{1, 1, -2, -2, 0, 0} mono := NormalizeStereoFrame([]int16{1, -2, 3}, false)
wantMono := []int16{1, 1, -2, -2, 3, 3}
if len(mono) != len(wantMono) {
t.Fatalf("mono len = %d, want %d", len(mono), len(wantMono))
}
for i := range wantMono { for i := range wantMono {
if mono[i] != wantMono[i] { if mono[i] != wantMono[i] {
t.Fatalf("mono[%d] = %d, want %d", i, mono[i], wantMono[i]) t.Fatalf("mono[%d] = %d, want %d", i, mono[i], wantMono[i])
} }
} }
stereo := NormalizeStereoFrame([]int16{1, 2, 3, 4, 5, 6}, 2) // Even-length mono must not be mistaken for stereo.
wantStereo := []int16{1, 2, 3, 4} monoEven := NormalizeStereoFrame([]int16{1, -2}, false)
wantMonoEven := []int16{1, 1, -2, -2}
if len(monoEven) != len(wantMonoEven) {
t.Fatalf("monoEven len = %d, want %d", len(monoEven), len(wantMonoEven))
}
for i := range wantMonoEven {
if monoEven[i] != wantMonoEven[i] {
t.Fatalf("monoEven[%d] = %d, want %d", i, monoEven[i], wantMonoEven[i])
}
}
// Stereo input passes through unchanged.
stereo := NormalizeStereoFrame([]int16{1, 2, 3, 4, 5, 6}, true)
wantStereo := []int16{1, 2, 3, 4, 5, 6}
if len(stereo) != len(wantStereo) {
t.Fatalf("stereo len = %d, want %d", len(stereo), len(wantStereo))
}
for i := range wantStereo { for i := range wantStereo {
if stereo[i] != wantStereo[i] { if stereo[i] != wantStereo[i] {
t.Fatalf("stereo[%d] = %d, want %d", i, stereo[i], wantStereo[i]) t.Fatalf("stereo[%d] = %d, want %d", i, stereo[i], wantStereo[i])