From fae372bb781a77e8e02cf88b5dc8254ea30dbeec Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 30 Nov 2025 20:31:06 -0500 Subject: [PATCH] Added /file and /stop commands. --- barnard.go | 6 + client.go | 17 +++ fileplayback/player.go | 241 ++++++++++++++++++++++++++++++++++ gumble/gumble/audio.go | 4 + gumble/gumble/client.go | 27 +++- gumble/gumble/handlers.go | 3 + gumble/gumbleopenal/stream.go | 112 ++++++++++++---- gumble/opus/opus.go | 10 ++ ui.go | 139 +++++++++++++++++++- 9 files changed, 533 insertions(+), 26 deletions(-) create mode 100644 fileplayback/player.go diff --git a/barnard.go b/barnard.go index f3e4544..a1c4e12 100644 --- a/barnard.go +++ b/barnard.go @@ -2,9 +2,11 @@ package main import ( "crypto/tls" + "sync" "git.stormux.org/storm/barnard/audio" "git.stormux.org/storm/barnard/config" + "git.stormux.org/storm/barnard/fileplayback" "git.stormux.org/storm/barnard/gumble/gumble" "git.stormux.org/storm/barnard/gumble/gumbleopenal" "git.stormux.org/storm/barnard/noise" @@ -51,6 +53,10 @@ type Barnard struct { // Added for voice effects VoiceEffects *audio.EffectsProcessor + + // Added for file playback + FileStream *fileplayback.Player + FileStreamMutex sync.Mutex } func (b *Barnard) StopTransmission() { diff --git a/client.go b/client.go index d95326f..351e75f 100644 --- a/client.go +++ b/client.go @@ -5,9 +5,11 @@ import ( "net" "time" + "git.stormux.org/storm/barnard/fileplayback" "git.stormux.org/storm/barnard/gumble/gumble" "git.stormux.org/storm/barnard/gumble/gumbleopenal" "git.stormux.org/storm/barnard/gumble/gumbleutil" + "git.stormux.org/storm/barnard/gumble/opus" ) func (b *Barnard) start() { @@ -51,6 +53,21 @@ func (b *Barnard) connect(reconnect bool) bool { b.Stream.AttachStream(b.Client) b.Stream.SetNoiseProcessor(b.NoiseSuppressor) b.Stream.SetEffectsProcessor(b.VoiceEffects) + + // Initialize stereo encoder for file playback + b.Client.AudioEncoderStereo = opus.NewStereoEncoder() + + // Initialize file player + b.FileStreamMutex.Lock() + b.FileStream = fileplayback.New(b.Client) + b.FileStream.SetErrorFunc(func(err error) { + // Disable stereo when file finishes or errors + b.Client.DisableStereoEncoder() + b.AddOutputLine(fmt.Sprintf("File playback: %s", err.Error())) + }) + b.Stream.SetFilePlayer(b.FileStream) + b.FileStreamMutex.Unlock() + b.Connected = true return true } diff --git a/fileplayback/player.go b/fileplayback/player.go new file mode 100644 index 0000000..5f1daf7 --- /dev/null +++ b/fileplayback/player.go @@ -0,0 +1,241 @@ +package fileplayback + +import ( + "encoding/binary" + "errors" + "io" + "os/exec" + "strconv" + "sync" + "time" + + "git.stormux.org/storm/barnard/gumble/gumble" + "git.stormux.org/storm/barnard/gumble/go-openal/openal" +) + +// Player handles file playback and mixing with microphone audio +type Player struct { + client *gumble.Client + filename string + audioChan chan gumble.AudioBuffer + stopChan chan struct{} + mutex sync.Mutex + playing bool + errorFunc func(error) + + // Local playback + localSource *openal.Source + localBuffers openal.Buffers +} + +// New creates a new file player +func New(client *gumble.Client) *Player { + return &Player{ + client: client, + audioChan: make(chan gumble.AudioBuffer, 100), + stopChan: make(chan struct{}), + } +} + +// SetErrorFunc sets the error callback function +func (p *Player) SetErrorFunc(f func(error)) { + p.mutex.Lock() + defer p.mutex.Unlock() + p.errorFunc = f +} + +func (p *Player) reportError(err error) { + p.mutex.Lock() + errorFunc := p.errorFunc + p.mutex.Unlock() + + if errorFunc != nil { + errorFunc(err) + } +} + +// PlayFile starts playing a file +func (p *Player) PlayFile(filename string) error { + p.mutex.Lock() + defer p.mutex.Unlock() + + if p.playing { + return errors.New("file already playing") + } + + p.filename = filename + + // Initialize local playback + source := openal.NewSource() + p.localSource = &source + p.localSource.SetGain(1.0) + + // Create buffers for local playback + p.localBuffers = openal.NewBuffers(64) + + // Start the file reading goroutine + p.playing = true + p.stopChan = make(chan struct{}) + go p.readFileAudio() + + return nil +} + +// Stop stops the currently playing file +func (p *Player) Stop() error { + p.mutex.Lock() + defer p.mutex.Unlock() + + if !p.playing { + return errors.New("no file playing") + } + + close(p.stopChan) + p.playing = false + + // Clean up local playback + if p.localSource != nil { + p.localSource.Stop() + p.localSource.Delete() + p.localSource = nil + } + if p.localBuffers != nil { + p.localBuffers.Delete() + p.localBuffers = nil + } + + // Drain the audio channel + for len(p.audioChan) > 0 { + <-p.audioChan + } + + return nil +} + +// IsPlaying returns true if a file is currently playing +func (p *Player) IsPlaying() bool { + p.mutex.Lock() + defer p.mutex.Unlock() + return p.playing +} + +// GetAudioFrame returns the next audio frame from the file, or nil if no file is playing +func (p *Player) GetAudioFrame() []int16 { + select { + case frame := <-p.audioChan: + return []int16(frame) + default: + return nil + } +} + +// playLocalAudio plays audio through the local OpenAL source +func (p *Player) playLocalAudio(data []byte) { + if p.localSource == nil { + return + } + + // Reclaim processed buffers + if n := p.localSource.BuffersProcessed(); n > 0 { + reclaimedBufs := make(openal.Buffers, n) + p.localSource.UnqueueBuffers(reclaimedBufs) + p.localBuffers = append(p.localBuffers, reclaimedBufs...) + } + + // If we have available buffers, queue more audio + if len(p.localBuffers) > 0 { + buffer := p.localBuffers[len(p.localBuffers)-1] + p.localBuffers = p.localBuffers[:len(p.localBuffers)-1] + + // Set buffer data as stereo + buffer.SetData(openal.FormatStereo16, data, gumble.AudioSampleRate) + p.localSource.QueueBuffer(buffer) + + // Start playing if not already + if p.localSource.State() != openal.Playing { + p.localSource.Play() + } + } +} + +// readFileAudio reads audio from the file via ffmpeg +func (p *Player) readFileAudio() { + interval := p.client.Config.AudioInterval + frameSize := p.client.Config.AudioFrameSize() + + // Use stereo output from ffmpeg to preserve stereo files + // Add -loglevel error to suppress info messages + args := []string{"-loglevel", "error", "-i", p.filename} + args = append(args, "-ac", "2", "-ar", strconv.Itoa(gumble.AudioSampleRate), "-f", "s16le", "-") + + cmd := exec.Command("ffmpeg", args...) + pipe, err := cmd.StdoutPipe() + if err != nil { + p.mutex.Lock() + p.playing = false + p.mutex.Unlock() + p.reportError(errors.New("failed to create ffmpeg pipe: " + err.Error())) + return + } + + if err := cmd.Start(); err != nil { + p.mutex.Lock() + p.playing = false + p.mutex.Unlock() + p.reportError(errors.New("failed to start ffmpeg: " + err.Error())) + return + } + + // Stereo has 2 channels, so we need twice the buffer size + byteBuffer := make([]byte, frameSize*2*2) // frameSize * 2 channels * 2 bytes per sample + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-p.stopChan: + cmd.Process.Kill() + cmd.Wait() + return + case <-ticker.C: + n, err := io.ReadFull(pipe, byteBuffer) + if err != nil || n != len(byteBuffer) { + // File finished playing + p.mutex.Lock() + p.playing = false + // Clean up local playback + if p.localSource != nil { + p.localSource.Stop() + p.localSource.Delete() + p.localSource = nil + } + if p.localBuffers != nil { + p.localBuffers.Delete() + p.localBuffers = nil + } + p.mutex.Unlock() + cmd.Wait() + // Notify that file finished + p.reportError(errors.New("file playback finished")) + return + } + + // Convert stereo bytes to int16 buffer + int16Buffer := make([]int16, frameSize*2) // stereo + for i := 0; i < len(int16Buffer); i++ { + int16Buffer[i] = int16(binary.LittleEndian.Uint16(byteBuffer[i*2 : (i+1)*2])) + } + + // Play locally through OpenAL + p.playLocalAudio(byteBuffer[:n]) + + // Send to channel (non-blocking) + select { + case p.audioChan <- gumble.AudioBuffer(int16Buffer): + default: + // Channel full, skip this frame + } + } + } +} diff --git a/gumble/gumble/audio.go b/gumble/gumble/audio.go index 305d9fb..c1d2b5b 100644 --- a/gumble/gumble/audio.go +++ b/gumble/gumble/audio.go @@ -53,7 +53,11 @@ type AudioStreamEvent struct { type AudioBuffer []int16 func (a AudioBuffer) writeAudio(client *Client, seq int64, final bool) error { + // Choose encoder based on whether stereo is enabled encoder := client.AudioEncoder + if client.IsStereoEncoderEnabled() && client.AudioEncoderStereo != nil { + encoder = client.AudioEncoderStereo + } if encoder == nil { return nil } diff --git a/gumble/gumble/client.go b/gumble/gumble/client.go index 5693e7d..7fcdbb2 100644 --- a/gumble/gumble/client.go +++ b/gumble/gumble/client.go @@ -59,8 +59,10 @@ type Client struct { ContextActions ContextActions // The audio encoder used when sending audio to the server. - AudioEncoder AudioEncoder - audioCodec AudioCodec + AudioEncoder AudioEncoder + AudioEncoderStereo AudioEncoder + audioCodec AudioCodec + useStereoEncoder bool // To whom transmitted audio will be sent. The VoiceTarget must have already // been sent to the server for targeting to work correctly. Setting to nil // will disable voice targeting (i.e. switch back to regular speaking). @@ -287,3 +289,24 @@ func (c *Client) Do(f func()) { func (c *Client) Send(message Message) { message.writeMessage(c) } + +// EnableStereoEncoder switches to stereo encoding for file playback. +func (c *Client) EnableStereoEncoder() { + c.volatile.Lock() + defer c.volatile.Unlock() + c.useStereoEncoder = true +} + +// DisableStereoEncoder switches back to mono encoding for voice. +func (c *Client) DisableStereoEncoder() { + c.volatile.Lock() + defer c.volatile.Unlock() + c.useStereoEncoder = false +} + +// IsStereoEncoderEnabled returns true if stereo encoding is currently active. +func (c *Client) IsStereoEncoderEnabled() bool { + c.volatile.RLock() + defer c.volatile.RUnlock() + return c.useStereoEncoder +} diff --git a/gumble/gumble/handlers.go b/gumble/gumble/handlers.go index e72741c..94f82d0 100644 --- a/gumble/gumble/handlers.go +++ b/gumble/gumble/handlers.go @@ -1073,6 +1073,9 @@ func (c *Client) handleCodecVersion(buffer []byte) error { c.volatile.Lock() c.AudioEncoder = codec.NewEncoder() + // Also create a stereo encoder for file playback + // Import the opus package to get NewStereoEncoder + c.AudioEncoderStereo = nil // Will be set when needed c.volatile.Unlock() } diff --git a/gumble/gumbleopenal/stream.go b/gumble/gumbleopenal/stream.go index d4c6e68..c7ede43 100644 --- a/gumble/gumbleopenal/stream.go +++ b/gumble/gumbleopenal/stream.go @@ -23,6 +23,12 @@ type EffectsProcessor interface { IsEnabled() bool } +// FilePlayer interface for file playback +type FilePlayer interface { + GetAudioFrame() []int16 + IsPlaying() bool +} + const ( maxBufferSize = 11520 // Max frame size (2880) * bytes per stereo sample (4) ) @@ -59,6 +65,7 @@ type Stream struct { noiseProcessor NoiseProcessor micAGC *audio.AGC effectsProcessor EffectsProcessor + filePlayer FilePlayer } func New(client *gumble.Client, inputDevice *string, outputDevice *string, test bool) (*Stream, error) { @@ -127,6 +134,14 @@ func (s *Stream) GetEffectsProcessor() EffectsProcessor { return s.effectsProcessor } +func (s *Stream) SetFilePlayer(fp FilePlayer) { + s.filePlayer = fp +} + +func (s *Stream) GetFilePlayer() FilePlayer { + return s.filePlayer +} + func (s *Stream) Destroy() { if s.link != nil { @@ -340,35 +355,88 @@ func (s *Stream) sourceRoutine(inputDevice *string) { case <-stop: return case <-ticker.C: - buff := s.deviceSource.CaptureSamples(uint32(frameSize)) - if len(buff) != frameSize*2 { - continue - } + // Initialize buffer with silence int16Buffer := make([]int16, frameSize) - for i := range int16Buffer { - sample := int16(binary.LittleEndian.Uint16(buff[i*2:])) - if s.micVolume != 1.0 { - sample = int16(float32(sample) * s.micVolume) + + // Capture microphone if available + hasMicInput := false + buff := s.deviceSource.CaptureSamples(uint32(frameSize)) + if len(buff) == frameSize*2 { + hasMicInput = true + for i := range int16Buffer { + sample := int16(binary.LittleEndian.Uint16(buff[i*2:])) + if s.micVolume != 1.0 { + sample = int16(float32(sample) * s.micVolume) + } + int16Buffer[i] = sample + } + + // Apply noise suppression if available and enabled + if s.noiseProcessor != nil && s.noiseProcessor.IsEnabled() { + s.noiseProcessor.ProcessSamples(int16Buffer) + } + + // Apply AGC to outgoing microphone audio (always enabled) + if s.micAGC != nil { + s.micAGC.ProcessSamples(int16Buffer) + } + + // Apply voice effects if available and enabled + if s.effectsProcessor != nil && s.effectsProcessor.IsEnabled() { + s.effectsProcessor.ProcessSamples(int16Buffer) } - int16Buffer[i] = sample - } - - // Apply noise suppression if available and enabled - if s.noiseProcessor != nil && s.noiseProcessor.IsEnabled() { - s.noiseProcessor.ProcessSamples(int16Buffer) } - // Apply AGC to outgoing microphone audio (always enabled) - if s.micAGC != nil { - s.micAGC.ProcessSamples(int16Buffer) + // Mix with or use file audio if playing + hasFileAudio := false + var outputBuffer []int16 + + if s.filePlayer != nil && s.filePlayer.IsPlaying() { + fileAudio := s.filePlayer.GetAudioFrame() + if fileAudio != nil && len(fileAudio) > 0 { + hasFileAudio = true + // File audio is stereo - send as stereo when file is playing + // Create stereo buffer (frameSize * 2 channels) + outputBuffer = make([]int16, frameSize*2) + + if hasMicInput { + // Mix mono mic with stereo file + for i := 0; i < frameSize; i++ { + if i*2+1 < len(fileAudio) { + // Left channel: mic + file left + left := int32(int16Buffer[i]) + int32(fileAudio[i*2]) + if left > 32767 { + left = 32767 + } else if left < -32768 { + left = -32768 + } + outputBuffer[i*2] = int16(left) + + // Right channel: mic + file right + right := int32(int16Buffer[i]) + int32(fileAudio[i*2+1]) + if right > 32767 { + right = 32767 + } else if right < -32768 { + right = -32768 + } + outputBuffer[i*2+1] = int16(right) + } + } + } else { + // Use file audio only (already stereo) + copy(outputBuffer, fileAudio[:frameSize*2]) + } + } } - // Apply voice effects if available and enabled - if s.effectsProcessor != nil && s.effectsProcessor.IsEnabled() { - s.effectsProcessor.ProcessSamples(int16Buffer) + // Determine what to send + if hasFileAudio { + // Send stereo buffer when file is playing + outgoing <- gumble.AudioBuffer(outputBuffer) + } else if hasMicInput { + // Send mono mic when no file is playing + outgoing <- gumble.AudioBuffer(int16Buffer) } - - outgoing <- gumble.AudioBuffer(int16Buffer) } } } diff --git a/gumble/opus/opus.go b/gumble/opus/opus.go index e0d6d64..54ed070 100644 --- a/gumble/opus/opus.go +++ b/gumble/opus/opus.go @@ -35,6 +35,16 @@ func (*generator) NewEncoder() gumble.AudioEncoder { } } +// NewStereoEncoder creates a stereo encoder for file playback +func NewStereoEncoder() gumble.AudioEncoder { + // Create stereo encoder for file playback + e, _ := opus.NewEncoder(gumble.AudioSampleRate, gumble.AudioChannels, opus.AppAudio) + e.SetBitrateToMax() + return &Encoder{ + e, + } +} + func (*generator) NewDecoder() gumble.AudioDecoder { // Create decoder with stereo support d, _ := opus.NewDecoder(gumble.AudioSampleRate, gumble.AudioChannels) diff --git a/ui.go b/ui.go index d33a9b0..a33667a 100644 --- a/ui.go +++ b/ui.go @@ -2,7 +2,7 @@ package main import ( "fmt" - "github.com/nsf/termbox-go" + "os" "os/exec" "strings" "time" @@ -10,6 +10,7 @@ import ( "git.stormux.org/storm/barnard/gumble/gumble" "git.stormux.org/storm/barnard/uiterm" "github.com/kennygrant/sanitize" + "github.com/nsf/termbox-go" ) const ( @@ -150,7 +151,7 @@ func (b *Barnard) CommandNoiseSuppressionToggle(ui *uiterm.Ui, cmd string) { enabled := !b.UserConfig.GetNoiseSuppressionEnabled() b.UserConfig.SetNoiseSuppressionEnabled(enabled) b.NoiseSuppressor.SetEnabled(enabled) - + if enabled { b.AddOutputLine("Noise suppression enabled") } else { @@ -158,6 +159,101 @@ func (b *Barnard) CommandNoiseSuppressionToggle(ui *uiterm.Ui, cmd string) { } } +func (b *Barnard) CommandPlayFile(ui *uiterm.Ui, cmd string) { + // cmd contains just the filename part (everything after "/file ") + filename := strings.TrimSpace(cmd) + if filename == "" { + b.AddOutputLine("Usage: /file ") + return + } + + // Check if it's a URL + isURL := strings.HasPrefix(filename, "http://") || + strings.HasPrefix(filename, "https://") || + strings.HasPrefix(filename, "ftp://") || + strings.HasPrefix(filename, "rtmp://") + + if !isURL { + // Expand ~ to home directory for local files + if strings.HasPrefix(filename, "~") { + homeDir := os.Getenv("HOME") + filename = strings.Replace(filename, "~", homeDir, 1) + } + + // Check if local file exists + if _, err := os.Stat(filename); os.IsNotExist(err) { + b.AddOutputLine(fmt.Sprintf("File not found: %s", filename)) + return + } + } + + if !b.Connected { + b.AddOutputLine("Not connected to server") + return + } + + b.FileStreamMutex.Lock() + defer b.FileStreamMutex.Unlock() + + if b.FileStream != nil && b.FileStream.IsPlaying() { + b.AddOutputLine("Already playing a file. Use /stop first.") + return + } + + err := b.FileStream.PlayFile(filename) + if err != nil { + b.AddOutputLine(fmt.Sprintf("Error playing file: %s", err.Error())) + return + } + + // Enable stereo encoder for file playback + b.Client.EnableStereoEncoder() + + // Auto-start transmission if not already transmitting + if !b.Tx { + err := b.Stream.StartSource(b.UserConfig.GetInputDevice()) + if err != nil { + b.AddOutputLine(fmt.Sprintf("Error starting transmission: %s", err.Error())) + b.FileStream.Stop() + b.Client.DisableStereoEncoder() + return + } + b.Tx = true + b.UpdateGeneralStatus(" File ", true) + } + + if isURL { + b.AddOutputLine(fmt.Sprintf("Streaming: %s (stereo)", filename)) + } else { + b.AddOutputLine(fmt.Sprintf("Playing: %s (stereo)", filename)) + } +} + +func (b *Barnard) CommandStopFile(ui *uiterm.Ui, cmd string) { + b.FileStreamMutex.Lock() + defer b.FileStreamMutex.Unlock() + + if b.FileStream == nil || !b.FileStream.IsPlaying() { + b.AddOutputLine("No file playing") + return + } + + err := b.FileStream.Stop() + if err != nil { + b.AddOutputLine(fmt.Sprintf("Error stopping file: %s", err.Error())) + return + } + + // Disable stereo encoder when file stops + b.Client.DisableStereoEncoder() + + b.AddOutputLine("File playback stopped") + + // Note: We keep transmission active even after file stops + // User can manually stop with talk key or it will stop when they're done talking + b.UpdateGeneralStatus(" Idle ", false) +} + func (b *Barnard) setTransmit(ui *uiterm.Ui, val int) { if b.Tx && val == 1 { @@ -257,6 +353,43 @@ func (b *Barnard) OnTextInput(ui *uiterm.Ui, textbox *uiterm.Textbox, text strin if text == "" { return } + + // Check if this is a command (starts with /) + if strings.HasPrefix(text, "/") { + // Remove the leading slash and process as command + cmdText := strings.TrimPrefix(text, "/") + parts := strings.SplitN(cmdText, " ", 2) + cmdName := parts[0] + cmdArgs := "" + if len(parts) > 1 { + cmdArgs = parts[1] + } + + // Handle built-in commands + switch cmdName { + case "file": + b.CommandPlayFile(ui, cmdArgs) + case "stop": + b.CommandStopFile(ui, cmdArgs) + case "exit": + b.CommandExit(ui, cmdArgs) + case "status": + b.CommandStatus(ui, cmdArgs) + case "noise": + b.CommandNoiseSuppressionToggle(ui, cmdArgs) + case "micup": + b.CommandMicUp(ui, cmdArgs) + case "micdown": + b.CommandMicDown(ui, cmdArgs) + case "toggle", "talk": + b.CommandTalk(ui, cmdArgs) + default: + b.AddOutputLine(fmt.Sprintf("Unknown command: /%s", cmdName)) + } + return + } + + // Not a command, send as chat message if b.Client != nil && b.Client.Self != nil { if b.selectedUser != nil { b.selectedUser.Send(text) @@ -325,6 +458,8 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) { b.Ui.AddCommandListener(b.CommandExit, "exit") b.Ui.AddCommandListener(b.CommandStatus, "status") b.Ui.AddCommandListener(b.CommandNoiseSuppressionToggle, "noise") + b.Ui.AddCommandListener(b.CommandPlayFile, "file") + b.Ui.AddCommandListener(b.CommandStopFile, "stop") b.Ui.AddKeyListener(b.OnFocusPress, b.Hotkeys.SwitchViews) b.Ui.AddKeyListener(b.OnVoiceToggle, b.Hotkeys.Talk) b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps)