From 90f8a1ca5ff59d9b4e3998fb8e098dfd2b0cf42f Mon Sep 17 00:00:00 2001 From: "Brandon McGinty (deepseek)" Date: Mon, 10 Aug 2026 18:34:52 -0400 Subject: [PATCH] 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 --- gumble/gumbleopenal/stream.go | 8 +++--- recording/recorder.go | 54 +++++++++++++++++++---------------- recording/recorder_test.go | 28 +++++++++++++++--- 3 files changed, 57 insertions(+), 33 deletions(-) diff --git a/gumble/gumbleopenal/stream.go b/gumble/gumbleopenal/stream.go index 30f216b..3cc64a4 100644 --- a/gumble/gumbleopenal/stream.go +++ b/gumble/gumbleopenal/stream.go @@ -45,7 +45,7 @@ type FilePlayer interface { } type Recorder interface { - RecordAudioFrame(source uint32, samples []int16) + RecordAudioFrame(source uint32, samples []int16, stereo bool) } const recorderOutgoingSource uint32 = ^uint32(0) @@ -742,7 +742,7 @@ func (s *Stream) processAudioPacket(packet *gumble.AudioPacket, user *gumble.Use } } if recorder != nil && recordPtr > 0 { - recorder.RecordAudioFrame(user.Session, recordBuffer[:recordPtr]) + recorder.RecordAudioFrame(user.Session, recordBuffer[:recordPtr], true) } 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 outgoing <- gumble.AudioBuffer(outputBuffer) if recorder := s.getRecorder(); recorder != nil { - recorder.RecordAudioFrame(recorderOutgoingSource, outputBuffer) + recorder.RecordAudioFrame(recorderOutgoingSource, outputBuffer, true) } } else if hasMicInput { // 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) if recorder := s.getRecorder(); recorder != nil { - recorder.RecordAudioFrame(recorderOutgoingSource, outBuf) + recorder.RecordAudioFrame(recorderOutgoingSource, outBuf, false) } } } diff --git a/recording/recorder.go b/recording/recorder.go index 6da9c83..82d896d 100644 --- a/recording/recorder.go +++ b/recording/recorder.go @@ -152,14 +152,14 @@ func (r *Recorder) Path() string { 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 { return } if len(r.input) >= cap(r.input) { return } - frame := NormalizeStereoFrame(samples, r.frameSize) + frame := NormalizeStereoFrame(samples, stereo) select { case r.input <- sourceFrame{source: source, samples: frame}: default: @@ -192,31 +192,35 @@ 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) + // Per-source accumulated stereo samples. Incoming frames of any size are + // 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 { select { case <-r.stop: r.closeEncoder() return 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: - clear(frame) - for source, queue := range queues { - if len(queue) == 0 { + clear(chunk) + for source, buffer := range queues { + if len(buffer) == 0 { delete(queues, source) continue } - mix(frame, queue[0]) - queue = queue[1:] - if len(queue) == 0 { + // Mix one chunk worth of samples from this source. + if len(buffer) <= chunkSize { + mix(chunk, buffer) delete(queues, source) } 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.closeEncoder() return @@ -248,19 +252,19 @@ func (r *Recorder) setError(err error) { } } -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 +// NormalizeStereoFrame ensures samples are in stereo interleaved format. +// If stereo is true the samples are returned as-is (already interleaved). +// Mono input is duplicated to both channels. The returned slice preserves +// all input audio without truncation. +func NormalizeStereoFrame(samples []int16, stereo bool) []int16 { + if stereo { + return samples } - 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] + // Convert mono to stereo by duplicating each sample. + out := make([]int16, len(samples)*gumble.AudioChannels) + for i, s := range samples { + out[i*2] = s + out[i*2+1] = s } return out } diff --git a/recording/recorder_test.go b/recording/recorder_test.go index 155cca9..10c2fc6 100644 --- a/recording/recorder_test.go +++ b/recording/recorder_test.go @@ -93,16 +93,36 @@ func TestReservePathPreventsConcurrentRecordingCollisions(t *testing.T) { } func TestNormalizeStereoFrame(t *testing.T) { - mono := NormalizeStereoFrame([]int16{1, -2}, 3) - wantMono := []int16{1, 1, -2, -2, 0, 0} + // Mono input duplicating each sample to both channels. + 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 { if 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) - wantStereo := []int16{1, 2, 3, 4} + // Even-length mono must not be mistaken for stereo. + 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 { if stereo[i] != wantStereo[i] { t.Fatalf("stereo[%d] = %d, want %d", i, stereo[i], wantStereo[i])