From aa6fdff11ebb4f674e3f4d2137518a7de1a2a41d Mon Sep 17 00:00:00 2001 From: Hua Audio Date: Sat, 21 Mar 2026 09:42:09 +0100 Subject: [PATCH] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/bus/types.go | 2 +- pkg/channels/discord/voice.go | 39 +++++++++++++++++++++++++++---- pkg/tts/tts.go | 44 +++++++++++++++++++++++++++++++---- 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 794db5b0f..c648fb0ce 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -70,5 +70,5 @@ type AudioChunk struct { type VoiceControl struct { SessionID string `json:"session_id"` Type string `json:"type"` // "state", "command" - Action string `json:"action"` // "idle", "listening", "start", "stop" + Action string `json:"action"` // "idle", "listening", "start", "stop", "leave" } diff --git a/pkg/channels/discord/voice.go b/pkg/channels/discord/voice.go index d5d5d303f..37c882b25 100644 --- a/pkg/channels/discord/voice.go +++ b/pkg/channels/discord/voice.go @@ -73,17 +73,46 @@ func streamOggOpusToDiscord(ctx context.Context, vc *discordgo.VoiceConnection, func (c *DiscordChannel) receiveVoice(vc *discordgo.VoiceConnection, guildID string, chatID string) { logger.InfoCF("discord", "Started listening for voice", map[string]any{"guild": guildID}) - go func() { + go func(ctx context.Context, vc *discordgo.VoiceConnection) { + // Recover from potential panics if OpusSend is closed mid-send. + defer func() { + if rec := recover(); rec != nil { + logger.WarnCF("discord", "Recovered from panic while sending wake-up frames", map[string]any{ + "error": rec, + "guild": guildID, + }) + } + }() + + // If the voice connection or OpusSend are not available, nothing to do. + if vc == nil || vc.OpusSend == nil { + return + } + time.Sleep(250 * time.Millisecond) // Wait a bit for connection to settle + + // Abort if the context has already been cancelled. + select { + case <-ctx.Done(): + return + default: + } + vc.Speaking(true) + defer vc.Speaking(false) + + silenceFrame := []byte{0xF8, 0xFF, 0xFE} for i := 0; i < 5; i++ { - vc.OpusSend <- []byte{0xF8, 0xFF, 0xFE} + select { + case <-ctx.Done(): + return + case vc.OpusSend <- silenceFrame: + } time.Sleep(20 * time.Millisecond) } - vc.Speaking(false) - logger.DebugCF("discord", "Sent wake-up silence frames", nil) - }() + logger.DebugCF("discord", "Sent wake-up silence frames", map[string]any{"guild": guildID}) + }(c.ctx, vc) sessionID := fmt.Sprintf("discord_vc_%s", guildID) c.bus.PublishVoiceControl(c.ctx, bus.VoiceControl{ diff --git a/pkg/tts/tts.go b/pkg/tts/tts.go index 63b4ecd24..25b533326 100644 --- a/pkg/tts/tts.go +++ b/pkg/tts/tts.go @@ -29,11 +29,47 @@ type OpenAITTSProvider struct { } func NewOpenAITTSProvider(apiKey string, apiBase string, proxyURL string) *OpenAITTSProvider { - if apiBase == "" || apiBase == "https://api.openai.com/v1" { + // Normalize apiBase to avoid malformed endpoints like + // "https://api.openai.com/audio/speech" when "/v1" is required. + if apiBase == "" { apiBase = "https://api.openai.com/v1/audio/speech" - } else if !strings.HasSuffix(apiBase, "/audio/speech") { - // Just in case they provide openrouter base or standard base - apiBase = strings.TrimSuffix(apiBase, "/") + "/audio/speech" + } else { + if u, err := url.Parse(apiBase); err == nil && u.Scheme != "" && u.Host != "" { + path := u.Path + if u.Host == "api.openai.com" { + // For the official OpenAI host, ensure exactly one /v1 prefix and + // that the path ends with /audio/speech. + if path == "" || path == "/" || path == "/v1" { + path = "/v1/audio/speech" + } else { + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + if !strings.HasPrefix(path, "/v1/") { + path = "/v1" + strings.TrimSuffix(path, "/") + } + if !strings.HasSuffix(path, "/audio/speech") { + path = strings.TrimSuffix(path, "/") + "/audio/speech" + } + } + } else { + // For non-OpenAI hosts (e.g., proxies), preserve the existing base + // path and only ensure it ends with /audio/speech. + if !strings.HasSuffix(path, "/audio/speech") { + path = strings.TrimSuffix(path, "/") + "/audio/speech" + } + } + u.Path = path + apiBase = u.String() + } else { + // Fallback to the previous string-based behavior if parsing fails. + if apiBase == "https://api.openai.com/v1" { + apiBase = "https://api.openai.com/v1/audio/speech" + } else if !strings.HasSuffix(apiBase, "/audio/speech") { + // Just in case they provide openrouter base or standard base + apiBase = strings.TrimSuffix(apiBase, "/") + "/audio/speech" + } + } } client := &http.Client{