150 lines
3.9 KiB
Go
150 lines
3.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
type ttsCfg struct {
|
|
Model string `json:"model"`
|
|
Voice string `json:"voice"`
|
|
Instruction string `json:"instruction"`
|
|
Rate float64 `json:"rate"`
|
|
Pitch float64 `json:"pitch"`
|
|
Volume int `json:"volume"`
|
|
}
|
|
|
|
func defaultTTS(cfg agentCfg) ttsCfg {
|
|
return ttsCfg{Model: cfg.TTSModel, Voice: cfg.TTSVoice, Instruction: cfg.TTSInstruction, Rate: 1, Pitch: 1, Volume: 50}
|
|
}
|
|
|
|
func normalizeTTS(in *ttsCfg, cfg agentCfg) (ttsCfg, error) {
|
|
if in == nil {
|
|
return defaultTTS(cfg), nil
|
|
}
|
|
t := *in
|
|
t.Model, t.Voice, t.Instruction = strings.TrimSpace(t.Model), strings.TrimSpace(t.Voice), strings.TrimSpace(t.Instruction)
|
|
if t.Model == "" {
|
|
t.Model = cfg.TTSModel
|
|
}
|
|
if t.Voice == "" {
|
|
t.Voice = cfg.TTSVoice
|
|
}
|
|
if t.Voice == "" {
|
|
return t, fmt.Errorf("请填写音色或复刻音色 ID")
|
|
}
|
|
if len(t.Model) > 100 || len(t.Voice) > 200 || len([]rune(t.Instruction)) > 100 {
|
|
return t, fmt.Errorf("TTS 配置文本过长")
|
|
}
|
|
if t.Rate < 0.5 || t.Rate > 2 || t.Pitch < 0.5 || t.Pitch > 2 || t.Volume < 0 || t.Volume > 100 {
|
|
return t, fmt.Errorf("TTS 参数超出范围:语速/语调 0.5~2.0,音量 0~100")
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// ttsSpeak: 百炼 CosyVoice 流式合成,音频块实时回调 onAudio。
|
|
func ttsSpeak(ctx context.Context, cfg agentCfg, tts ttsCfg, text string, onAudio func([]byte)) error {
|
|
cctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
|
defer cancel()
|
|
conn, _, err := websocket.DefaultDialer.DialContext(cctx, cfg.BailianWssBaseURL,
|
|
map[string][]string{"Authorization": {"Bearer " + cfg.BailianKey}})
|
|
if err != nil {
|
|
return fmt.Errorf("连接 TTS: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
taskID := uuid()
|
|
parameters := map[string]any{
|
|
"text_type": "PlainText", "voice": tts.Voice,
|
|
"format": "pcm", "sample_rate": 16000,
|
|
"volume": tts.Volume, "rate": tts.Rate, "pitch": tts.Pitch,
|
|
}
|
|
if tts.Instruction != "" {
|
|
parameters["instruction"] = tts.Instruction
|
|
}
|
|
err = conn.WriteJSON(map[string]any{
|
|
"header": map[string]string{"action": "run-task", "task_id": taskID, "streaming": "duplex"},
|
|
"payload": map[string]any{
|
|
"task_group": "audio", "task": "tts", "function": "SpeechSynthesizer",
|
|
"model": tts.Model,
|
|
"parameters": parameters,
|
|
"input": map[string]any{},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("TTS run-task: %w", err)
|
|
}
|
|
|
|
var once sync.Once
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
for {
|
|
mt, data, err := conn.ReadMessage()
|
|
if err != nil {
|
|
select {
|
|
case done <- err:
|
|
default:
|
|
}
|
|
return
|
|
}
|
|
if mt == websocket.BinaryMessage {
|
|
onAudio(data)
|
|
continue
|
|
}
|
|
var ev struct {
|
|
Header struct {
|
|
Event string `json:"event"`
|
|
ErrorMessage string `json:"error_message"`
|
|
} `json:"header"`
|
|
}
|
|
if json.Unmarshal(data, &ev) != nil {
|
|
continue
|
|
}
|
|
switch ev.Header.Event {
|
|
case "task-started":
|
|
once.Do(func() {
|
|
// task-started 到达后才允许发文本
|
|
if err := conn.WriteJSON(map[string]any{
|
|
"header": map[string]string{"action": "continue-task", "task_id": taskID, "streaming": "duplex"},
|
|
"payload": map[string]any{"input": map[string]string{"text": text}},
|
|
}); err != nil {
|
|
select {
|
|
case done <- err:
|
|
default:
|
|
}
|
|
}
|
|
conn.WriteJSON(map[string]any{
|
|
"header": map[string]string{"action": "finish-task", "task_id": taskID, "streaming": "duplex"},
|
|
"payload": map[string]any{"input": map[string]any{}},
|
|
})
|
|
})
|
|
case "task-finished":
|
|
select {
|
|
case done <- nil:
|
|
default:
|
|
}
|
|
return
|
|
case "task-failed":
|
|
select {
|
|
case done <- fmt.Errorf("TTS 失败: %s", ev.Header.ErrorMessage):
|
|
default:
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
select {
|
|
case err := <-done:
|
|
return err
|
|
case <-cctx.Done():
|
|
conn.Close()
|
|
return cctx.Err()
|
|
}
|
|
}
|