update with streaming support

This commit is contained in:
Huaaudio 2026-03-21 08:24:17 +01:00
parent 03b212b6d8
commit 347eabd0de
3 changed files with 213 additions and 10 deletions

96
pkg/audio/sentence.go Normal file
View file

@ -0,0 +1,96 @@
package audio
import (
"strings"
"unicode"
)
// SplitSentences splits text into sentence-sized chunks suitable for TTS synthesis.
// It splits on sentence-ending punctuation (.!?\n) while avoiding false splits
// on abbreviations and decimal numbers. Very short fragments are merged with
// the next sentence to prevent choppy playback.
func SplitSentences(text string) []string {
if text == "" {
return nil
}
var sentences []string
var current strings.Builder
runes := []rune(text)
for i := 0; i < len(runes); i++ {
r := runes[i]
current.WriteRune(r)
if r == '\n' {
s := strings.TrimSpace(current.String())
if s != "" {
sentences = append(sentences, s)
}
current.Reset()
continue
}
if r == '.' || r == '!' || r == '?' {
// Avoid splitting on decimal numbers like "3.14"
if r == '.' && i > 0 && unicode.IsDigit(runes[i-1]) &&
i+1 < len(runes) && unicode.IsDigit(runes[i+1]) {
continue
}
// Consume trailing punctuation and spaces (e.g., "..." or "?!")
for i+1 < len(runes) && (runes[i+1] == '.' || runes[i+1] == '!' || runes[i+1] == '?' || runes[i+1] == ' ') {
i++
current.WriteRune(runes[i])
}
s := strings.TrimSpace(current.String())
if s != "" {
sentences = append(sentences, s)
}
current.Reset()
}
}
// Flush remaining text
if s := strings.TrimSpace(current.String()); s != "" {
sentences = append(sentences, s)
}
// Merge very short fragments with the next sentence
return mergeShorties(sentences, 15)
}
// mergeShorties merges sentences shorter than minLen characters with the following sentence.
func mergeShorties(sentences []string, minLen int) []string {
if len(sentences) <= 1 {
return sentences
}
var merged []string
var buf string
for _, s := range sentences {
if buf != "" {
buf += " " + s
if len([]rune(buf)) >= minLen {
merged = append(merged, buf)
buf = ""
}
} else if len([]rune(s)) < minLen {
buf = s
} else {
merged = append(merged, s)
}
}
if buf != "" {
if len(merged) > 0 {
merged[len(merged)-1] += " " + buf
} else {
merged = append(merged, buf)
}
}
return merged
}

View file

@ -3,6 +3,7 @@ package discord
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@ -14,6 +15,7 @@ import (
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/sipeed/picoclaw/pkg/audio"
"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"
@ -45,6 +47,10 @@ type DiscordChannel struct {
botUserID string // stored for mention checking botUserID string // stored for mention checking
bus *bus.MessageBus bus *bus.MessageBus
tts tts.TTSProvider tts tts.TTSProvider
// TTS interruption: cancel active playback when user speaks
ttsMu sync.Mutex
cancelTTS context.CancelFunc
} }
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
@ -151,7 +157,16 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
if c.tts != nil { if c.tts != nil {
if ch, err := c.session.State.Channel(channelID); err == nil && ch.GuildID != "" { if ch, err := c.session.State.Channel(channelID); err == nil && ch.GuildID != "" {
if vc, ok := c.session.VoiceConnections[ch.GuildID]; ok && vc != nil { if vc, ok := c.session.VoiceConnections[ch.GuildID]; ok && vc != nil {
go c.playTTS(context.Background(), vc, msg.Content) // Cancel any previous TTS playback
c.ttsMu.Lock()
if c.cancelTTS != nil {
c.cancelTTS()
}
ttsCtx, ttsCancel := context.WithCancel(context.Background())
c.cancelTTS = ttsCancel
c.ttsMu.Unlock()
go c.playTTS(ttsCtx, vc, msg.Content)
} }
} }
} }
@ -652,14 +667,73 @@ func (c *DiscordChannel) listenVoiceControl(ctx context.Context) {
} }
func (c *DiscordChannel) playTTS(ctx context.Context, vc *discordgo.VoiceConnection, text string) { func (c *DiscordChannel) playTTS(ctx context.Context, vc *discordgo.VoiceConnection, text string) {
stream, err := c.tts.Synthesize(ctx, text) sentences := audio.SplitSentences(text)
if err != nil { if len(sentences) == 0 {
logger.ErrorCF("discord", "TTS synthesize failed", map[string]any{"error": err.Error()})
return return
} }
defer stream.Close()
if err := streamOggOpusToDiscord(vc, stream); err != nil { logger.InfoCF("discord", "Starting streamed TTS", map[string]any{"sentences": len(sentences)})
logger.ErrorCF("discord", "TTS playback failed", map[string]any{"error": err.Error()})
// Pipeline: prefetch next sentence's audio while playing current
type ttResult struct {
stream io.ReadCloser
err error
}
var prefetch chan ttResult
for i, sentence := range sentences {
// Check for cancellation (interruption)
select {
case <-ctx.Done():
logger.InfoCF("discord", "TTS interrupted", map[string]any{"at_sentence": i})
return
default:
}
// Start prefetching the NEXT sentence while we process the current one
var nextPrefetch chan ttResult
if i+1 < len(sentences) {
nextPrefetch = make(chan ttResult, 1)
nextSentence := sentences[i+1]
go func() {
s, e := c.tts.Synthesize(ctx, nextSentence)
nextPrefetch <- ttResult{s, e}
}()
}
// Get the current sentence's audio
var stream io.ReadCloser
var err error
if prefetch != nil {
// Use prefetched result from previous iteration
result := <-prefetch
stream, err = result.stream, result.err
} else {
// First sentence: synthesize directly
stream, err = c.tts.Synthesize(ctx, sentence)
}
if err != nil {
logger.ErrorCF("discord", "TTS synthesize failed", map[string]any{"error": err.Error(), "sentence": i})
prefetch = nextPrefetch
continue
}
if err := streamOggOpusToDiscord(ctx, vc, stream); err != nil {
logger.ErrorCF("discord", "TTS playback failed", map[string]any{"error": err.Error(), "sentence": i})
}
stream.Close()
prefetch = nextPrefetch
}
// Drain any leftover prefetch
if prefetch != nil {
result := <-prefetch
if result.stream != nil {
result.stream.Close()
}
} }
} }

View file

@ -2,6 +2,7 @@ package discord
import ( import (
"bytes" "bytes"
"context"
"fmt" "fmt"
"io" "io"
"time" "time"
@ -46,7 +47,7 @@ func VoiceReceiveActive(vc *discordgo.VoiceConnection) bool {
return vc != nil && vc.OpusRecv != nil return vc != nil && vc.OpusRecv != nil
} }
func streamOggOpusToDiscord(vc *discordgo.VoiceConnection, r io.Reader) error { func streamOggOpusToDiscord(ctx context.Context, vc *discordgo.VoiceConnection, r io.Reader) error {
// Wait for the speaking transition to register // Wait for the speaking transition to register
vc.Speaking(true) vc.Speaking(true)
defer vc.Speaking(false) defer vc.Speaking(false)
@ -55,6 +56,13 @@ func streamOggOpusToDiscord(vc *discordgo.VoiceConnection, r io.Reader) error {
header := make([]byte, 27) header := make([]byte, 27)
for { for {
// Check for interruption
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if _, err := io.ReadFull(r, header); err != nil { if _, err := io.ReadFull(r, header); err != nil {
if err == io.EOF || err == io.ErrUnexpectedEOF { if err == io.EOF || err == io.ErrUnexpectedEOF {
return nil return nil
@ -84,8 +92,11 @@ func streamOggOpusToDiscord(vc *discordgo.VoiceConnection, r io.Reader) error {
if len(packet) > 0 { if len(packet) > 0 {
// Ignore Ogg Opus headers // Ignore Ogg Opus headers
if !bytes.HasPrefix(packet, []byte("OpusHead")) && !bytes.HasPrefix(packet, []byte("OpusTags")) { if !bytes.HasPrefix(packet, []byte("OpusHead")) && !bytes.HasPrefix(packet, []byte("OpusTags")) {
// Pacing is handled natively by vc.OpusSend blocking (it has an internal ticker) select {
vc.OpusSend <- packet case <-ctx.Done():
return ctx.Err()
case vc.OpusSend <- packet:
}
} }
// Start new packet // Start new packet
packet = nil packet = nil
@ -118,6 +129,8 @@ func (c *DiscordChannel) receiveVoice(vc *discordgo.VoiceConnection, guildID str
}) })
var sequence uint64 = 0 var sequence uint64 = 0
var interruptCount int
var lastInterruptAt time.Time
for { for {
select { select {
@ -139,6 +152,26 @@ func (c *DiscordChannel) receiveVoice(vc *discordgo.VoiceConnection, guildID str
continue continue
} }
// Interruption detection: if user sends voice while TTS is playing,
// cancel TTS after a short debounce (3 packets in 200ms)
now := time.Now()
if now.Sub(lastInterruptAt) > 500*time.Millisecond {
interruptCount = 0
}
interruptCount++
lastInterruptAt = now
if interruptCount >= 3 {
c.ttsMu.Lock()
if c.cancelTTS != nil {
c.cancelTTS()
c.cancelTTS = nil
logger.InfoCF("discord", "TTS interrupted by user voice", nil)
}
c.ttsMu.Unlock()
interruptCount = 0
}
sequence++ sequence++
chunk := bus.AudioChunk{ chunk := bus.AudioChunk{