Add --tone-test mode: 440 Hz Opus tone + incoming audio capture

Adds a --tone-test flag that bypasses all OpenAL/soundcard
initialization and instead:

- Generates a 440 Hz sine wave at 48kHz mono, encodes it via Opus,
  and sends it to the Mumble server.

- Saves all incoming decoded audio to a raw PCM file (s16le,
  stereo, 48kHz) specified by --tone-out (default: incoming.pcm).

Useful for end-to-end testing of the Opus encode/decode pipeline
and Mumble transport without requiring physical audio hardware.

Usage:
  barnard --server HOST --tone-test --tone-out /tmp/in.pcm

  ffplay -f s16le -ar 48000 -ac 2 incoming.pcm
This commit is contained in:
Brandon McGinty (deepseek)
2026-08-09 00:19:41 -04:00
committed by Brandon McGinty
parent b2a1d2f846
commit 29be821155
4 changed files with 206 additions and 6 deletions
+141
View File
@@ -0,0 +1,141 @@
package main
import (
"encoding/binary"
"fmt"
"math"
"os"
"sync"
"time"
"git.stormux.org/storm/barnard/gumble/gumble"
)
// ---------------------------------------------------------------------------
// 440 Hz tone generator
// ---------------------------------------------------------------------------
// StartToneGenerator begins generating a 440 Hz sine wave and writing it to
// the client's outgoing audio channel at the configured interval. It blocks
// until the stop channel is closed.
func StartToneGenerator(client *gumble.Client, stop <-chan struct{}) {
interval := client.Config.AudioInterval
frameSize := client.Config.AudioFrameSize() // mono samples per frame
sampleRate := float64(gumble.AudioSampleRate)
frequency := 440.0
// Pre-compute one full sine wave cycle so we can just index into it.
// This avoids calling math.Sin in the hot loop.
phase := 0.0
phaseIncrement := 2.0 * math.Pi * frequency / sampleRate
ticker := time.NewTicker(interval)
defer ticker.Stop()
outgoing := client.AudioOutgoing()
defer close(outgoing)
fmt.Fprintf(os.Stderr, "tonetest: starting 440 Hz tone generator (frameSize=%d, interval=%v)\n",
frameSize, interval)
for {
select {
case <-stop:
fmt.Fprintf(os.Stderr, "tonetest: tone generator stopped\n")
return
case <-ticker.C:
buf := make([]int16, frameSize)
for i := 0; i < frameSize; i++ {
// Generate sine wave with amplitude 0.5 to avoid clipping
buf[i] = int16(math.Sin(phase) * 16000) // ~ -6dBFS
phase += phaseIncrement
if phase > 2.0*math.Pi {
phase -= 2.0 * math.Pi
}
}
outgoing <- gumble.AudioBuffer(buf)
}
}
}
// ---------------------------------------------------------------------------
// Incoming audio file saver
// ---------------------------------------------------------------------------
// AudioFileSaver implements gumble.AudioListener and writes all incoming PCM
// audio to a single raw 16-bit little-endian stereo 48kHz file.
type AudioFileSaver struct {
file *os.File
stop chan struct{}
mu sync.Mutex
wg sync.WaitGroup
}
// NewAudioFileSaver creates the output file and returns a configured saver.
// The file is raw PCM: s16le, stereo, 48000 Hz.
// Play it back with:
//
// ffplay -f s16le -ar 48000 -ac 2 <file>
//
// or convert with:
//
// ffmpeg -f s16le -ar 48000 -ac 2 -i <file> output.wav
func NewAudioFileSaver(path string) (*AudioFileSaver, error) {
f, err := os.Create(path)
if err != nil {
return nil, err
}
fmt.Fprintf(os.Stderr, "tonetest: saving incoming audio to %s\n", path)
return &AudioFileSaver{
file: f,
stop: make(chan struct{}),
}, nil
}
// Stop closes the stop channel, waits for all stream goroutines to finish,
// and closes the output file.
func (s *AudioFileSaver) Stop() {
close(s.stop)
s.wg.Wait()
s.file.Close()
}
// OnAudioStream implements gumble.AudioListener.
func (s *AudioFileSaver) OnAudioStream(e *gumble.AudioStreamEvent) {
fmt.Fprintf(os.Stderr, "tonetest: incoming audio stream from %s\n", e.User.Name)
s.wg.Add(1)
go func() {
defer s.wg.Done()
for {
select {
case <-s.stop:
return
case packet, ok := <-e.C:
if !ok {
fmt.Fprintf(os.Stderr, "tonetest: audio stream from %s ended\n", e.User.Name)
return
}
samples := packet.AudioBuffer
if len(samples) == 0 {
continue
}
// Write as raw PCM s16le. The decoder outputs stereo
// interleaved, so the sample count already accounts for
// both channels.
buf := make([]byte, len(samples)*2)
for i, s := range samples {
binary.LittleEndian.PutUint16(buf[i*2:], uint16(s))
}
s.mu.Lock()
if _, err := s.file.Write(buf); err != nil {
s.mu.Unlock()
fmt.Fprintf(os.Stderr, "tonetest: write error: %v\n", err)
return
}
s.mu.Unlock()
}
}
}()
}