add a tone test mode that bypasses the soundcard

Add -tone-test to transmit a generated 440 Hz Opus tone and save
incoming audio to the file named by -tone-out.
Diagnosing an audio problem previously required a working capture and
playback device, which is exactly what is in question. This mode opens
no OpenAL device at all, so the network and codec path can be tested
on a machine with no sound hardware.

Open the capture file before starting the generator.
Starting transmission first left a tone goroutine running with nowhere
to write when the path was unusable.

Refuse file playback while in tone test mode, and ignore the
microphone volume keys.
Both operate on a stream that does not exist here.

Stop the generator and detach the file saver on disconnect.
A reconnect otherwise attached a second saver to the same file.

Add -auto-transmit to key the microphone as soon as the connection is
up.
Useful for a bot or a monitoring client that should never need a
keypress. It runs from both the connect event and the point where the
audio stream is created, because the server's welcome arrives before
the stream exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon McGinty
2026-08-20 14:42:52 -04:00
co-authored by Claude Opus 5
parent 17a4662c6c
commit ecca0a63ed
6 changed files with 354 additions and 14 deletions
+19
View File
@@ -29,6 +29,7 @@ type Barnard struct {
Stream *gumbleopenal.Stream Stream *gumbleopenal.Stream
Tx bool Tx bool
AutoTransmit bool // auto-start transmission on connect
Connected bool Connected bool
Ui *uiterm.Ui Ui *uiterm.Ui
@@ -62,6 +63,13 @@ type Barnard struct {
FileStream *fileplayback.Player FileStream *fileplayback.Player
FileStreamMutex sync.Mutex FileStreamMutex sync.Mutex
// Added for tone test mode (bypasses all soundcard/OpenAL)
ToneTest bool
ToneTestOutput string
toneTestStop chan struct{}
toneTestSaver *AudioFileSaver
toneTestSaverDetach gumble.Detacher
// Added for recording // Added for recording
RecordingMutex sync.Mutex RecordingMutex sync.Mutex
Recorder *recording.Recorder Recorder *recording.Recorder
@@ -74,6 +82,17 @@ type Barnard struct {
adminACL *gumble.ACL adminACL *gumble.ACL
} }
func (b *Barnard) cleanupToneTestAudio() {
if b.toneTestSaverDetach != nil {
b.toneTestSaverDetach.Detach()
b.toneTestSaverDetach = nil
}
if b.toneTestSaver != nil {
b.toneTestSaver.Stop()
b.toneTestSaver = nil
}
}
func (b *Barnard) StopTransmission() { func (b *Barnard) StopTransmission() {
if b.Tx { if b.Tx {
b.Notify("micdown", "me", "") b.Notify("micdown", "me", "")
+64
View File
@@ -17,16 +17,27 @@ func (b *Barnard) start() {
b.Config.Attach(gumbleutil.AutoBitrate) b.Config.Attach(gumbleutil.AutoBitrate)
b.Config.Attach(b) b.Config.Attach(b)
b.Config.Address = b.Address b.Config.Address = b.Address
if b.ToneTest {
// Tone test mode: skip all OpenAL/soundcard initialization.
// We just need a network connection — the tone generator and
// file saver are set up in connect().
} else {
// test Audio // test Audio
_, err := gumbleopenal.New(b.Client, b.UserConfig.GetInputDevice(), b.UserConfig.GetOutputDevice(), true) _, err := gumbleopenal.New(b.Client, b.UserConfig.GetInputDevice(), b.UserConfig.GetOutputDevice(), true)
if err != nil { if err != nil {
b.exitWithError(err) b.exitWithError(err)
return return
} }
}
//connect, not reconnect //connect, not reconnect
b.connect(false) b.connect(false)
} }
func (b *Barnard) toneTestAutoTransmit() bool {
return b.ToneTest && b.AutoTransmit
}
func (b *Barnard) exitWithError(err error) { func (b *Barnard) exitWithError(err error) {
b.Ui.Close() b.Ui.Close()
b.exitStatus = 1 b.exitStatus = 1
@@ -45,6 +56,31 @@ func (b *Barnard) connect(reconnect bool) bool {
return false return false
} }
if b.ToneTest {
// --- Tone test mode: skip all OpenAL; generate 440 Hz tone
// --- and save incoming audio to a file.
// Open the output first. Starting transmission before this succeeds
// leaves an orphaned tone goroutine when the path is unusable.
saver, err := NewAudioFileSaver(b.ToneTestOutput)
if err != nil {
b.exitWithError(err)
return false
}
b.toneTestSaver = saver
b.toneTestSaverDetach = b.Client.Config.AttachAudio(saver)
b.Connected = true
if b.toneTestAutoTransmit() {
b.toneTestStop = make(chan struct{})
go StartToneGenerator(b.Client, b.toneTestStop)
b.Tx = true
b.UpdateGeneralStatus(" Tx ", true)
b.AddOutputLine("Tone test transmission started")
}
return true
}
stream, err := gumbleopenal.New(b.Client, b.UserConfig.GetInputDevice(), b.UserConfig.GetOutputDevice(), false) stream, err := gumbleopenal.New(b.Client, b.UserConfig.GetInputDevice(), b.UserConfig.GetOutputDevice(), false)
if err != nil { if err != nil {
b.exitWithError(err) b.exitWithError(err)
@@ -71,6 +107,9 @@ func (b *Barnard) connect(reconnect bool) bool {
b.FileStreamMutex.Unlock() b.FileStreamMutex.Unlock()
b.Connected = true b.Connected = true
// Dial delivers OnConnect before connect creates the OpenAL stream, so
// start auto-transmit here as well for initial connections and reconnects.
b.startAutoTransmit()
return true return true
} }
@@ -105,6 +144,21 @@ func (b *Barnard) OnConnect(e *gumble.ConnectEvent) {
b.AddOutputLine(fmt.Sprintf("Welcome message: %s", wmsg)) b.AddOutputLine(fmt.Sprintf("Welcome message: %s", wmsg))
} }
b.Ui.Refresh() b.Ui.Refresh()
b.startAutoTransmit()
}
func (b *Barnard) startAutoTransmit() {
if !b.AutoTransmit || b.Tx || b.Stream == nil {
return
}
if err := b.Stream.StartSource(b.UserConfig.GetInputDevice()); err != nil {
b.AddOutputLine(fmt.Sprintf("auto-transmit failed: %s", err.Error()))
return
}
b.Tx = true
b.UpdateGeneralStatus(" AutoTx ", true)
b.AddOutputLine("Auto-transmit started")
} }
func (b *Barnard) OnDisconnect(e *gumble.DisconnectEvent) { func (b *Barnard) OnDisconnect(e *gumble.DisconnectEvent) {
@@ -121,6 +175,16 @@ func (b *Barnard) OnDisconnect(e *gumble.DisconnectEvent) {
reason = e.String reason = e.String
} }
b.stopRecordingForDisconnect() b.stopRecordingForDisconnect()
// Tone test cleanup
if b.ToneTest {
if b.toneTestStop != nil {
close(b.toneTestStop)
b.toneTestStop = nil
}
b.cleanupToneTestAudio()
}
b.Notify("disconnect", "me", reason) b.Notify("disconnect", "me", reason)
if reason == "" { if reason == "" {
b.AddOutputLine("Disconnected") b.AddOutputLine("Disconnected")
+6
View File
@@ -88,6 +88,9 @@ func main() {
jitterBuffer := flag.Int("jitter-buffer", 40, "incoming per-user audio buffer in ms (0, 20, 40, or 60)") jitterBuffer := flag.Int("jitter-buffer", 40, "incoming per-user audio buffer in ms (0, 20, 40, or 60)")
profile := flag.Bool("profile", false, "add http server to serve profiles") profile := flag.Bool("profile", false, "add http server to serve profiles")
noiseSuppressionEnabled := flag.Bool("noise-suppression", false, "enable noise suppression for microphone input") noiseSuppressionEnabled := flag.Bool("noise-suppression", false, "enable noise suppression for microphone input")
autoTransmit := flag.Bool("auto-transmit", false, "start transmitting immediately on connect")
toneTest := flag.Bool("tone-test", false, "send a 440 Hz test tone instead of microphone (bypasses soundcard)")
toneTestOutput := flag.String("tone-out", "incoming.pcm", "file to save incoming audio to in tone-test mode")
tcpOnly := flag.Bool("tcp", false, "disable UDP, force audio through TCP tunnel") tcpOnly := flag.Bool("tcp", false, "disable UDP, force audio through TCP tunnel")
logLevel := flag.String("log", "warn", "log level: debug, info, warn, error") logLevel := flag.String("log", "warn", "log level: debug, info, warn, error")
logFile := flag.String("logfile", "", "write logs to this file (logging is disabled when omitted)") logFile := flag.String("logfile", "", "write logs to this file (logging is disabled when omitted)")
@@ -194,6 +197,9 @@ func main() {
Config: gumble.NewConfig(), Config: gumble.NewConfig(),
UserConfig: userConfig, UserConfig: userConfig,
Address: *server, Address: *server,
AutoTransmit: *autoTransmit,
ToneTest: *toneTest,
ToneTestOutput: *toneTestOutput,
MutedChannels: make(map[uint32]bool), MutedChannels: make(map[uint32]bool),
NoiseSuppressor: noise.NewSuppressor(), NoiseSuppressor: noise.NewSuppressor(),
} }
+159
View File
@@ -0,0 +1,159 @@
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
stopped bool
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.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
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() {
s.mu.Lock()
if s.stopped {
s.mu.Unlock()
return
}
s.stopped = true
close(s.stop)
s.mu.Unlock()
s.wg.Wait()
s.mu.Lock()
_ = s.file.Close()
s.mu.Unlock()
}
// OnAudioStream implements gumble.AudioListener.
func (s *AudioFileSaver) OnAudioStream(e *gumble.AudioStreamEvent) {
s.mu.Lock()
if s.stopped {
s.mu.Unlock()
return
}
s.wg.Add(1)
s.mu.Unlock()
fmt.Fprintf(os.Stderr, "tonetest: incoming audio stream from %s\n", e.User.Name)
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()
}
}
}()
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// Regression: tone transmission was started before opening its output file,
// so a creation error left audio transmission running without cleanup.
// Regression: tone test transmitted immediately, preventing the configured
// talk key from controlling it.
func TestToneTestRequiresAutoTransmit(t *testing.T) {
if (&Barnard{ToneTest: true}).toneTestAutoTransmit() {
t.Fatal("tone test started without auto-transmit")
}
if !(&Barnard{ToneTest: true, AutoTransmit: true}).toneTestAutoTransmit() {
t.Fatal("tone test did not auto-transmit")
}
}
func TestNewAudioFileSaverReportsUnavailableOutputPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "missing", "tone.pcm")
if saver, err := NewAudioFileSaver(path); err == nil || saver != nil {
t.Fatalf("got saver=%v err=%v", saver, err)
}
}
func TestNewAudioFileSaverDoesNotOverwriteExistingOutput(t *testing.T) {
path := filepath.Join(t.TempDir(), "tone.pcm")
if err := os.WriteFile(path, []byte("existing"), 0600); err != nil {
t.Fatal(err)
}
if saver, err := NewAudioFileSaver(path); err == nil || saver != nil {
t.Fatalf("got saver=%v err=%v", saver, err)
}
contents, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(contents) != "existing" {
t.Fatalf("output was overwritten: %q", contents)
}
}
type testDetacher struct{ detached bool }
func (d *testDetacher) Detach() { d.detached = true }
// Regression: reconnecting tone-test mode kept prior savers attached to the
// shared audio listener list, causing callbacks to write to closed files.
func TestCleanupToneTestAudioDetachesSaver(t *testing.T) {
saver, err := NewAudioFileSaver(filepath.Join(t.TempDir(), "tone.pcm"))
if err != nil {
t.Fatal(err)
}
detacher := &testDetacher{}
b := &Barnard{toneTestSaver: saver, toneTestSaverDetach: detacher}
b.cleanupToneTestAudio()
if !detacher.detached {
t.Fatal("tone saver listener was not detached")
}
if b.toneTestSaver != nil || b.toneTestSaverDetach != nil {
t.Fatal("tone saver cleanup retained connection state")
}
b.cleanupToneTestAudio()
}
+24
View File
@@ -245,6 +245,10 @@ func (b *Barnard) CommandPlayFile(ui *uiterm.Ui, cmd string) {
b.AddOutputLine("Not connected to server") b.AddOutputLine("Not connected to server")
return return
} }
if b.ToneTest {
b.AddOutputLine("File playback is unavailable in tone test mode")
return
}
b.FileStreamMutex.Lock() b.FileStreamMutex.Lock()
defer b.FileStreamMutex.Unlock() defer b.FileStreamMutex.Unlock()
@@ -319,7 +323,14 @@ func (b *Barnard) setTransmit(ui *uiterm.Ui, val int) {
b.Notify("micdown", "me", "") b.Notify("micdown", "me", "")
b.Tx = false b.Tx = false
b.UpdateGeneralStatus(" Idle ", false) b.UpdateGeneralStatus(" Idle ", false)
if b.ToneTest {
if b.toneTestStop != nil {
close(b.toneTestStop)
b.toneTestStop = nil
}
} else {
b.Stream.StopSource() b.Stream.StopSource()
}
} else if b.Connected == false { } else if b.Connected == false {
b.Notify("error", "me", "no tx while disconnected") b.Notify("error", "me", "no tx while disconnected")
b.Tx = false b.Tx = false
@@ -331,6 +342,12 @@ func (b *Barnard) setTransmit(ui *uiterm.Ui, val int) {
b.UpdateGeneralStatus("cannot transmit in muted channel", true) b.UpdateGeneralStatus("cannot transmit in muted channel", true)
} else { } else {
b.Tx = true b.Tx = true
if b.ToneTest {
b.toneTestStop = make(chan struct{})
go StartToneGenerator(b.Client, b.toneTestStop)
b.Notify("micup", "me", "")
b.UpdateGeneralStatus(" Tx ", true)
} else {
err := b.Stream.StartSource(b.UserConfig.GetInputDevice()) err := b.Stream.StartSource(b.UserConfig.GetInputDevice())
if err != nil { if err != nil {
b.Notify("error", "me", err.Error()) b.Notify("error", "me", err.Error())
@@ -340,9 +357,13 @@ func (b *Barnard) setTransmit(ui *uiterm.Ui, val int) {
b.UpdateGeneralStatus(" Tx ", true) b.UpdateGeneralStatus(" Tx ", true)
} }
} }
}
} }
func (b *Barnard) OnMicVolumeDown(ui *uiterm.Ui, key uiterm.Key) { func (b *Barnard) OnMicVolumeDown(ui *uiterm.Ui, key uiterm.Key) {
if b.ToneTest {
return
}
b.Stream.SetMicVolume(-0.1, true) b.Stream.SetMicVolume(-0.1, true)
b.UserConfig.SetMicVolume(b.Stream.GetMicVolume()) b.UserConfig.SetMicVolume(b.Stream.GetMicVolume())
if err := b.UserConfig.SaveConfig(); err != nil { if err := b.UserConfig.SaveConfig(); err != nil {
@@ -351,6 +372,9 @@ func (b *Barnard) OnMicVolumeDown(ui *uiterm.Ui, key uiterm.Key) {
} }
func (b *Barnard) OnMicVolumeUp(ui *uiterm.Ui, key uiterm.Key) { func (b *Barnard) OnMicVolumeUp(ui *uiterm.Ui, key uiterm.Key) {
if b.ToneTest {
return
}
b.Stream.SetMicVolume(0.1, true) b.Stream.SetMicVolume(0.1, true)
b.UserConfig.SetMicVolume(b.Stream.GetMicVolume()) b.UserConfig.SetMicVolume(b.Stream.GetMicVolume())
if err := b.UserConfig.SaveConfig(); err != nil { if err := b.UserConfig.SaveConfig(); err != nil {