package main import ( "flag" "log" "os" "os/signal" "strings" "sync" "syscall" "time" "go-ts/pkg/ts3client" "go-ts/pkg/xai" ) // VoiceSession represents an active xAI voice session for a user type VoiceSession struct { ClientID uint16 Nickname string XAI *xai.Client AudioBuffer []int16 // Buffer to accumulate audio samples AudioQueue chan []int16 // Queue for sending audio with proper timing done chan struct{} // Signal to stop audio sender } // Bot manages the TeamSpeak connection and xAI sessions type Bot struct { ts3 *ts3client.Client apiKey string voice string prompt string greeting string // Optional greeting when user joins selfID uint16 // Our own ClientID sessions map[uint16]*VoiceSession sessionsMu sync.RWMutex startTime time.Time } func main() { serverAddr := flag.String("server", "127.0.0.1:9987", "TeamSpeak 3 Server Address") nickname := flag.String("nickname", "GrokBot", "Bot nickname") voice := flag.String("voice", xai.VoiceAra, "xAI voice (Ara, Rex, Sal, Eve, Leo)") greeting := flag.String("greeting", "Saluda brevemente al usuario que acaba de unirse.", "Greeting message (empty to disable)") flag.Parse() apiKey := os.Getenv("XAI_API_KEY") if apiKey == "" { log.Fatal("XAI_API_KEY environment variable not set") } log.Println("=== xAI Voice Bot for TeamSpeak ===") log.Printf("Server: %s", *serverAddr) log.Printf("Nickname: %s", *nickname) log.Printf("Voice: %s", *voice) bot := &Bot{ apiKey: apiKey, voice: *voice, prompt: "Eres Grok, un asistente de voz amigable y útil. Responde de forma concisa y natural.", greeting: *greeting, sessions: make(map[uint16]*VoiceSession), startTime: time.Now(), } // Create TeamSpeak client bot.ts3 = ts3client.New(*serverAddr, ts3client.Config{ Nickname: *nickname, }) // Register event handlers bot.ts3.On(ts3client.EventConnected, func(e *ts3client.ConnectedEvent) { bot.selfID = e.ClientID // Store our own ID log.Printf("✓ Conectado a TeamSpeak! ClientID=%d, Server=%s", e.ClientID, e.ServerName) }) bot.ts3.On(ts3client.EventChannelList, func(e *ts3client.ChannelListEvent) { log.Printf("✓ %d canales disponibles", len(e.Channels)) }) bot.ts3.On(ts3client.EventClientEnter, func(e *ts3client.ClientEnterEvent) { log.Printf("→ Usuario entró: %s (ID=%d)", e.Nickname, e.ClientID) // Don't create session for ourselves (compare by ID, not nickname) if e.ClientID == bot.selfID { log.Printf(" (Soy yo, ignorando)") return } // Create xAI session for this user go bot.createSession(e.ClientID, e.Nickname, bot.greeting) }) bot.ts3.On(ts3client.EventClientLeft, func(e *ts3client.ClientLeftEvent) { log.Printf("← Usuario salió: ID=%d (%s)", e.ClientID, e.Reason) // Close xAI session for this user bot.closeSession(e.ClientID) }) bot.ts3.On(ts3client.EventAudio, func(e *ts3client.AudioEvent) { // Forward audio from TeamSpeak to all xAI sessions // Forward audio ONLY to the sender's session bot.sessionsMu.RLock() if session, ok := bot.sessions[e.SenderID]; ok { if session.XAI != nil && session.XAI.IsConnected() { session.XAI.SendAudio(e.PCM) } } bot.sessionsMu.RUnlock() }) bot.ts3.On(ts3client.EventError, func(e *ts3client.ErrorEvent) { if e.ID != "0" { log.Printf("! Error del servidor: [%s] %s", e.ID, e.Message) } }) // Handle shutdown shutdownDone := make(chan struct{}) go func() { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) <-sigChan log.Println("Cerrando...") // Close all xAI sessions and audio senders first bot.sessionsMu.Lock() for _, session := range bot.sessions { // Close audio sender first select { case <-session.done: // Already closed default: close(session.done) } // Then close xAI if session.XAI != nil { session.XAI.Close() } } bot.sessionsMu.Unlock() // Wait for audio senders to stop time.Sleep(200 * time.Millisecond) // Now disconnect from TeamSpeak bot.ts3.Disconnect() // Signal main that we are done close(shutdownDone) }() // Start global audio mixer go bot.runAudioMixer(shutdownDone) // Connect to TeamSpeak if err := bot.ts3.Connect(); err != nil { // If connect returns error, check if it's because we're shutting down select { case <-shutdownDone: // Normal shutdown log.Println("Conexión cerrada por shutdown") default: log.Fatalf("Error de conexión: %v", err) } } // Wait for shutdown to complete if we returned from Connect cleanly log.Println("Esperando confirmación final de shutdown...") <-shutdownDone log.Println("Shutdown completado. Saliendo.") os.Exit(0) } // createSession creates a new xAI voice session for a user func (b *Bot) createSession(clientID uint16, nickname string, greeting string) { log.Printf("[Session] Creando sesión xAI para %s...", nickname) // Create session with audio queue session := &VoiceSession{ ClientID: clientID, Nickname: nickname, AudioBuffer: make([]int16, 0, 960*10), AudioQueue: make(chan []int16, 500), // Buffer up to 500 frames (~10 sec) done: make(chan struct{}), } // Start audio sender goroutine with proper 20ms timing // Global audio mixer handles sending // go b.audioSender(session) xaiClient := xai.New(b.apiKey) // Set up audio callback - buffer and queue in 960-sample chunks xaiClient.OnAudio(func(pcm []int16) { b.sessionsMu.Lock() session.AudioBuffer = append(session.AudioBuffer, pcm...) // Queue complete 960-sample frames for len(session.AudioBuffer) >= 960 { frame := make([]int16, 960) copy(frame, session.AudioBuffer[:960]) session.AudioBuffer = session.AudioBuffer[960:] // Non-blocking send to queue select { case session.AudioQueue <- frame: default: // Queue full, drop frame } } b.sessionsMu.Unlock() }) // Set up transcript callback for logging xaiClient.OnTranscript(func(text string) { log.Printf("[Grok] %s", text) }) // Clear audio queue when user starts speaking (interruption) xaiClient.OnSpeechStarted(func() { // Disable queue clearing for now to prevent cutting off greetings due to sensitive VAD /* b.sessionsMu.Lock() // Clear the buffer session.AudioBuffer = session.AudioBuffer[:0] // Drain the queue for len(session.AudioQueue) > 0 { <-session.AudioQueue } b.sessionsMu.Unlock() */ log.Printf("[Session] Speech started by %s (VAD) - Ignoring interruption to ensure playback", nickname) }) // Connect to xAI if err := xaiClient.Connect(); err != nil { log.Printf("[Session] Error conectando a xAI: %v", err) close(session.done) return } // Configure the session if err := xaiClient.ConfigureSession(b.voice, b.prompt); err != nil { log.Printf("[Session] Error configurando sesión: %v", err) xaiClient.Close() close(session.done) return } // Store the xAI client in session session.XAI = xaiClient b.sessionsMu.Lock() b.sessions[clientID] = session b.sessionsMu.Unlock() log.Printf("[Session] ✓ Sesión xAI activa para %s", nickname) // Send greeting to start conversation (if configured) // Send greeting to start conversation (if configured) if strings.TrimSpace(greeting) != "" { // Only greet if we are past the startup grace period (3 seconds) // This prevents "Greeting Storm" when joining a channel with existing users if time.Since(b.startTime) > 3*time.Second { go func() { time.Sleep(500 * time.Millisecond) // Small delay for session to stabilize if err := xaiClient.SendText(greeting); err != nil { log.Printf("[Session] Error enviando saludo: %v", err) } }() } else { log.Printf("[Session] Omitiendo saludo inicial para %s (sesión existente detectada en arranque)", nickname) } } } // runAudioMixer mixes audio from all active sessions and sends it to TeamSpeak func (b *Bot) runAudioMixer(stop <-chan struct{}) { ticker := time.NewTicker(20 * time.Millisecond) defer ticker.Stop() mixedFrame := make([]int16, 960) for { select { case <-stop: return case <-ticker.C: hasAudio := false // Zero output buffer for i := range mixedFrame { mixedFrame[i] = 0 } b.sessionsMu.RLock() for _, session := range b.sessions { // Try to get a frame from the queue select { case frame := <-session.AudioQueue: hasAudio = true // Mix (Sum and Clamp) for i := 0; i < 960; i++ { if i >= len(frame) { break } val := int32(mixedFrame[i]) + int32(frame[i]) if val > 32767 { val = 32767 } if val < -32768 { val = -32768 } mixedFrame[i] = int16(val) } default: // No audio from this session } } b.sessionsMu.RUnlock() if hasAudio { if err := b.ts3.SendAudio(mixedFrame); err != nil { log.Printf("[Mixer] Error sending audio: %v", err) } } } } } // closeSession closes an xAI session for a user func (b *Bot) closeSession(clientID uint16) { b.sessionsMu.Lock() defer b.sessionsMu.Unlock() if session, ok := b.sessions[clientID]; ok { log.Printf("[Session] Cerrando sesión xAI para %s", session.Nickname) if session.XAI != nil { session.XAI.Close() } delete(b.sessions, clientID) } }