add tts support

This commit is contained in:
Huaaudio 2026-03-21 07:53:28 +01:00
parent e23e1793f1
commit a0ee78cff4
4 changed files with 192 additions and 1 deletions

View file

@ -21,6 +21,7 @@ import (
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
"github.com/sipeed/picoclaw/pkg/voice"
) )
const ( const (
@ -43,6 +44,7 @@ type DiscordChannel struct {
typingStop map[string]chan struct{} // chatID → stop signal typingStop map[string]chan struct{} // chatID → stop signal
botUserID string // stored for mention checking botUserID string // stored for mention checking
bus *bus.MessageBus bus *bus.MessageBus
tts voice.TTSProvider
} }
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
@ -146,6 +148,14 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
return nil return nil
} }
if c.tts != nil {
if ch, err := c.session.State.Channel(channelID); err == nil && ch.GuildID != "" {
if vc, ok := c.session.VoiceConnections[ch.GuildID]; ok && vc != nil {
go c.playTTS(context.Background(), vc, msg.Content)
}
}
}
return c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID) return c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID)
} }
@ -640,3 +650,16 @@ func (c *DiscordChannel) listenVoiceControl(ctx context.Context) {
} }
} }
} }
func (c *DiscordChannel) playTTS(ctx context.Context, vc *discordgo.VoiceConnection, text string) {
stream, err := c.tts.Synthesize(ctx, text)
if err != nil {
logger.ErrorCF("discord", "TTS synthesize failed", map[string]any{"error": err.Error()})
return
}
defer stream.Close()
if err := streamOggOpusToDiscord(vc, stream); err != nil {
logger.ErrorCF("discord", "TTS playback failed", map[string]any{"error": err.Error()})
}
}

View file

@ -4,10 +4,15 @@ import (
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/voice"
) )
func init() { func init() {
channels.RegisterFactory("discord", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { channels.RegisterFactory("discord", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewDiscordChannel(cfg.Channels.Discord, b) ch, err := NewDiscordChannel(cfg.Channels.Discord, b)
if err == nil {
ch.tts = voice.DetectTTS(cfg)
}
return ch, err
}) })
} }

View file

@ -1,7 +1,9 @@
package discord package discord
import ( import (
"bytes"
"fmt" "fmt"
"io"
"time" "time"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
@ -40,6 +42,59 @@ func (c *DiscordChannel) handleVoiceCommand(s *discordgo.Session, m *discordgo.M
return false return false
} }
func VoiceReceiveActive(vc *discordgo.VoiceConnection) bool {
return vc != nil && vc.OpusRecv != nil
}
func streamOggOpusToDiscord(vc *discordgo.VoiceConnection, r io.Reader) error {
// Wait for the speaking transition to register
vc.Speaking(true)
defer vc.Speaking(false)
var packet []byte
header := make([]byte, 27)
for {
if _, err := io.ReadFull(r, header); err != nil {
if err == io.EOF || err == io.ErrUnexpectedEOF {
return nil
}
return fmt.Errorf("failed to read ogg header: %w", err)
}
if string(header[:4]) != "OggS" {
return fmt.Errorf("invalid ogg magic string")
}
pageSegments := int(header[26])
segmentTable := make([]byte, pageSegments)
if _, err := io.ReadFull(r, segmentTable); err != nil {
return fmt.Errorf("failed to read segment table: %w", err)
}
for _, lacing := range segmentTable {
segment := make([]byte, lacing)
if _, err := io.ReadFull(r, segment); err != nil {
return fmt.Errorf("failed to read segment data: %w", err)
}
packet = append(packet, segment...)
// If lacing is less than 255, the packet is complete
if lacing < 255 {
if len(packet) > 0 {
// Ignore Ogg Opus headers
if !bytes.HasPrefix(packet, []byte("OpusHead")) && !bytes.HasPrefix(packet, []byte("OpusTags")) {
// Pacing is handled natively by vc.OpusSend blocking (it has an internal ticker)
vc.OpusSend <- packet
}
// Start new packet
packet = nil
}
}
}
}
}
func (c *DiscordChannel) receiveVoice(vc *discordgo.VoiceConnection, guildID string, chatID string) { func (c *DiscordChannel) receiveVoice(vc *discordgo.VoiceConnection, guildID string, chatID string) {
logger.InfoCF("discord", "Started listening for voice", map[string]any{"guild": guildID}) logger.InfoCF("discord", "Started listening for voice", map[string]any{"guild": guildID})

108
pkg/voice/tts.go Normal file
View file

@ -0,0 +1,108 @@
package voice
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
type TTSProvider interface {
Name() string
Synthesize(ctx context.Context, text string) (io.ReadCloser, error)
}
type OpenAITTSProvider struct {
apiKey string
apiBase string
voice string
model string
httpClient *http.Client
}
func NewOpenAITTSProvider(apiKey string, apiBase string, proxyURL string) *OpenAITTSProvider {
if apiBase == "" || 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{
Timeout: 60 * time.Second,
}
if proxyURL != "" {
if pURL, err := url.Parse(proxyURL); err == nil {
client.Transport = &http.Transport{
Proxy: http.ProxyURL(pURL),
}
}
}
return &OpenAITTSProvider{
apiKey: apiKey,
apiBase: apiBase,
voice: "alloy",
model: "tts-1",
httpClient: client,
}
}
func (t *OpenAITTSProvider) Name() string {
return "openai-tts"
}
func (t *OpenAITTSProvider) Synthesize(ctx context.Context, text string) (io.ReadCloser, error) {
logger.InfoCF("voice-tts", "Starting TTS synthesis", map[string]any{"text_len": len(text)})
reqBody := map[string]any{
"model": t.model,
"input": text,
"voice": t.voice,
"response_format": "opus",
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", t.apiBase, bytes.NewReader(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+t.apiKey)
resp, err := t.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
return resp.Body, nil
}
func DetectTTS(cfg *config.Config) TTSProvider {
for _, mc := range cfg.ModelList {
if strings.Contains(strings.ToLower(mc.ModelName), "tts") && mc.APIKey != "" {
return NewOpenAITTSProvider(mc.APIKey, mc.APIBase, mc.Proxy)
}
}
return nil
}