From ecca0a63ed339d892c2b580b5926e85f0f8845a1 Mon Sep 17 00:00:00 2001 From: Brandon McGinty Date: Thu, 20 Aug 2026 14:36:52 -0400 Subject: [PATCH] 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 --- barnard.go | 25 +++++++- client.go | 74 ++++++++++++++++++++-- main.go | 6 ++ tonetest.go | 159 +++++++++++++++++++++++++++++++++++++++++++++++ tonetest_test.go | 68 ++++++++++++++++++++ ui.go | 36 +++++++++-- 6 files changed, 354 insertions(+), 14 deletions(-) create mode 100644 tonetest.go create mode 100644 tonetest_test.go diff --git a/barnard.go b/barnard.go index 3dac560..04e1e02 100644 --- a/barnard.go +++ b/barnard.go @@ -27,9 +27,10 @@ type Barnard struct { Address string TLSConfig tls.Config - Stream *gumbleopenal.Stream - Tx bool - Connected bool + Stream *gumbleopenal.Stream + Tx bool + AutoTransmit bool // auto-start transmission on connect + Connected bool Ui *uiterm.Ui UiOutput uiterm.Textview @@ -62,6 +63,13 @@ type Barnard struct { FileStream *fileplayback.Player 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 RecordingMutex sync.Mutex Recorder *recording.Recorder @@ -74,6 +82,17 @@ type Barnard struct { 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() { if b.Tx { b.Notify("micdown", "me", "") diff --git a/client.go b/client.go index 639d9df..a5f8ac8 100644 --- a/client.go +++ b/client.go @@ -17,16 +17,27 @@ func (b *Barnard) start() { b.Config.Attach(gumbleutil.AutoBitrate) b.Config.Attach(b) b.Config.Address = b.Address - // test Audio - _, err := gumbleopenal.New(b.Client, b.UserConfig.GetInputDevice(), b.UserConfig.GetOutputDevice(), true) - if err != nil { - b.exitWithError(err) - return + + 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 + _, err := gumbleopenal.New(b.Client, b.UserConfig.GetInputDevice(), b.UserConfig.GetOutputDevice(), true) + if err != nil { + b.exitWithError(err) + return + } } //connect, not reconnect b.connect(false) } +func (b *Barnard) toneTestAutoTransmit() bool { + return b.ToneTest && b.AutoTransmit +} + func (b *Barnard) exitWithError(err error) { b.Ui.Close() b.exitStatus = 1 @@ -45,6 +56,31 @@ func (b *Barnard) connect(reconnect bool) bool { 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) if err != nil { b.exitWithError(err) @@ -71,6 +107,9 @@ func (b *Barnard) connect(reconnect bool) bool { b.FileStreamMutex.Unlock() 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 } @@ -105,6 +144,21 @@ func (b *Barnard) OnConnect(e *gumble.ConnectEvent) { b.AddOutputLine(fmt.Sprintf("Welcome message: %s", wmsg)) } 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) { @@ -121,6 +175,16 @@ func (b *Barnard) OnDisconnect(e *gumble.DisconnectEvent) { reason = e.String } 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) if reason == "" { b.AddOutputLine("Disconnected") diff --git a/main.go b/main.go index 8ddde60..c91feae 100644 --- a/main.go +++ b/main.go @@ -88,6 +88,9 @@ func main() { 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") 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") 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)") @@ -194,6 +197,9 @@ func main() { Config: gumble.NewConfig(), UserConfig: userConfig, Address: *server, + AutoTransmit: *autoTransmit, + ToneTest: *toneTest, + ToneTestOutput: *toneTestOutput, MutedChannels: make(map[uint32]bool), NoiseSuppressor: noise.NewSuppressor(), } diff --git a/tonetest.go b/tonetest.go new file mode 100644 index 0000000..95d929c --- /dev/null +++ b/tonetest.go @@ -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 +// +// or convert with: +// +// ffmpeg -f s16le -ar 48000 -ac 2 -i 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() + } + } + }() +} diff --git a/tonetest_test.go b/tonetest_test.go new file mode 100644 index 0000000..c31da2a --- /dev/null +++ b/tonetest_test.go @@ -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() +} diff --git a/ui.go b/ui.go index 2b6f31c..e66848e 100644 --- a/ui.go +++ b/ui.go @@ -245,6 +245,10 @@ func (b *Barnard) CommandPlayFile(ui *uiterm.Ui, cmd string) { b.AddOutputLine("Not connected to server") return } + if b.ToneTest { + b.AddOutputLine("File playback is unavailable in tone test mode") + return + } b.FileStreamMutex.Lock() defer b.FileStreamMutex.Unlock() @@ -319,7 +323,14 @@ func (b *Barnard) setTransmit(ui *uiterm.Ui, val int) { b.Notify("micdown", "me", "") b.Tx = false b.UpdateGeneralStatus(" Idle ", false) - b.Stream.StopSource() + if b.ToneTest { + if b.toneTestStop != nil { + close(b.toneTestStop) + b.toneTestStop = nil + } + } else { + b.Stream.StopSource() + } } else if b.Connected == false { b.Notify("error", "me", "no tx while disconnected") b.Tx = false @@ -331,18 +342,28 @@ func (b *Barnard) setTransmit(ui *uiterm.Ui, val int) { b.UpdateGeneralStatus("cannot transmit in muted channel", true) } else { b.Tx = true - err := b.Stream.StartSource(b.UserConfig.GetInputDevice()) - if err != nil { - b.Notify("error", "me", err.Error()) - b.UpdateGeneralStatus(err.Error(), true) - } else { + 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()) + if err != nil { + b.Notify("error", "me", err.Error()) + b.UpdateGeneralStatus(err.Error(), true) + } else { + b.Notify("micup", "me", "") + b.UpdateGeneralStatus(" Tx ", true) + } } } } func (b *Barnard) OnMicVolumeDown(ui *uiterm.Ui, key uiterm.Key) { + if b.ToneTest { + return + } b.Stream.SetMicVolume(-0.1, true) b.UserConfig.SetMicVolume(b.Stream.GetMicVolume()) 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) { + if b.ToneTest { + return + } b.Stream.SetMicVolume(0.1, true) b.UserConfig.SetMicVolume(b.Stream.GetMicVolume()) if err := b.UserConfig.SaveConfig(); err != nil {