Bootstrap

长文本切割实现流式调用文本合成语音

长文本切割实现TTS文本合成语音HTTP流式输出

  • 下面是一个文本合成音频的接口文档

快速 TTS 音频构造接口文档

  • 请求地址:
http://52.83.113.111:13679/Say/api/ra
请求方式:
 post xml raw
请求参数:
字段名称字段作用数据格式(示例)
*****xml结构体string(32)<speak xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="http://www.w3.org/2001/mstts" xmlns:emo="http://www.w3.org/2009/10/emotionml" version="1.0" xml:lang="en-US"> <voice name="zh-CN-YunJianNeural"> <prosody rate="0%" pitch="0%"> 如果喜欢这个项目的话请点个 Star 吧。 </prosody > </voice > </speak >
format请求头标识stringaudio-24khz-48kbitrate-mono-mp3
DeviceNo请求头参数string319ee32b4715017e60b1ab5b6c0ea69f
参数备注:

可选vioce name:

{
        '中国大陆播音男口音1': 'zh-CN-YunjianNeural',
        '中国大陆播音男口音3': 'zh-CN-YunyangNeural',
        '中国大陆少女口音1': 'zh-CN-XiaoxiaoNeural',
        '中国大陆少女口音2': 'zh-CN-XiaoyiNeural',
        '中国大陆少女口音3': 'zh-CN-YunxiaNeural',
    }
返回参数:

二进制音频流

根据文档要求构建接口

// 向TTS API请求音频数据
func fetchAudioToTTS(text string, voicer string, format string) ([]byte, error) {
	url := "http://52.83.116.11:13679/Say/api/ra"

	payload := "<speak xmlns=\"http://www.w3.org/2001/10/synthesis\" " +
		"xmlns:mstts=\"http://www.w3.org/2001/mstts\" " +
		"xmlns:emo=\"http://www.w3.org/2009/10/emotionml\" " +
		"version=\"1.0\" " +
		"xml:lang=\"en-US\">" +
		"<voice name=\"" + voicer + "\">" +
		"<prosody rate=\"0%\" pitch=\"0%\">" +
		text +
		"</prosody>" +
		"</voice>" +
		"</speak>"

	fmt.Println(" ========================================== ")

	fmt.Println(" payload : =>", payload)
	// 设置请求头
	req, err := http.NewRequest("POST", url, strings.NewReader(payload))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/xml")
	req.Header.Set("format", format)

	// 发送请求并获取响应
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		fmt.Println("fetchAudioToTTS err => ", err.Error())
		return nil, err
	}
	defer resp.Body.Close()

	// 读取音频流
	audioData, err := io.ReadAll(resp.Body)
	if err != nil {
		fmt.Println("读取音频流 err => ", err.Error())
		return nil, err
	}

	return audioData, nil
}

长文本切割,多协程合成音频,websocket流式输出音频流代码

package service

import (
	"bytes"
	"fmt"
	"github.com/gin-gonic/gin"
	"github.com/gorilla/websocket"
	"io"
	"log"
	"net/http"
	"strings"
	"sync"
	"time"
)

// 定义常用标点符号
var punctuationMarks = []string{".", ",", "。", "!", "?", ";", ":", "、", "·"}

// 定义音频格式和TTS相关信息
//var voicer = "zh-CN-YunjianNeural"

//var deviceNo = "319ee32b4715017e60b1ab5b6c0ea69f"
var format = "audio-24khz-48kbitrate-mono-mp3"

//var audioServers = []string{
//	"http://server1.com/audio1.mp3",
//	"http://server2.com/audio2.mp3",
//	"http://server3.com/audio3.mp3",
//}

var upgrader = websocket.Upgrader{
	CheckOrigin: func(r *http.Request) bool {
		return true
	},
}

const bufferSize = 1024 * 32 // 32KB

// 创建一个带互斥锁的缓冲区结构体,用于存储音频数据
type AudioBuffer struct {
	buffer bytes.Buffer
	mutex  sync.Mutex
}

func (ab *AudioBuffer) Write(p []byte) (n int, err error) {
	ab.mutex.Lock()
	defer ab.mutex.Unlock()
	return ab.buffer.Write(p)
}

func (ab *AudioBuffer) Read(p []byte) (n int, err error) {
	ab.mutex.Lock()
	defer ab.mutex.Unlock()
	return ab.buffer.Read(p)
}

// 定义标点符号集合(可以根据实际需求扩展)
var PunctuationMarks = []string{",", ",", "。", "!", "!", "?", "?", "\n"}

// computeLen 根据 UTF-8 编码,按照标点符号进行切割,确保每段不超过 15 个汉字
func computeLen(content string) (string, string) {
	Sr := ""      // 用于存储最终返回的已处理文本
	cp := content // 临时存储输入的文本内容

	// 获取文本中的汉字数量,按UTF-8编码切割
	for {
		// 如果剩余的文本为空或只剩最后一个字符,停止匹配切割
		if len(cp) == 0 {
			break
		}

		// 查找第一个符合的标点符号
		sAim := findFirstMatch(cp)

		// 如果剩余的文本长度较短或者已满足条件,直接添加到 Sr
		// 判断:如果当前已经处理的文本长度加上剩余文本长度 <= 15(即不超过15个汉字)
		if len(Sr)+len(cp) <= 15 {
			Sr += cp // 将剩余的文本直接添加到 Sr
			cp = ""  // 剩余文本清空
			break    // 处理完成,退出循环
		}

		// 查找切割点,确保切割后的文本不超过 15 个汉字
		// 如果当前已经处理的文本长度小于15,并且找到一个符合条件的标点符号
		if len(Sr) < 15 && sAim != "" {
			fmt.Println("sAim => ", sAim)
			fmt.Println("len(Sr) => ", len(Sr))

			worldCount := 0 // 汉字计数器
			index := 0      // 字符串切割的索引位置

			// 遍历字符串,统计汉字数量(按字符数统计,处理UTF-8编码)
			for index = range cp {
				// 如果已处理的汉字数量大于等于15,则停止
				if worldCount >= 15 {
					fmt.Println("汉字数量大于等于15 cp => ", len(cp))
					break
				}

				// 增加汉字计数
				worldCount++
			}

			// 将符合条件的文本(从开始到切割点)添加到 Sr
			Sr += cp[:index]
			// 剩余的文本部分,更新 cp
			cp = cp[index:]
			// 处理完成,退出循环
			break
		}
	}

	// 返回切割后的结果:已处理的文本和剩余的文本
	return Sr, cp
}

// computeLen 根据 UTF-8 编码,按照标点符号进行切割,确保每段不超过 30 个汉字
func computeLen22(content string) (string, string) {
	Sr := ""      // 用于存储最终返回的已处理文本
	cp := content // 临时存储输入的文本内容

	// 获取文本中的汉字数量,按UTF-8编码切割
	for {
		// 如果剩余的文本为空或只剩最后一个字符,停止匹配切割
		if len(cp) == 0 {
			break
		}

		// 查找第一个符合的标点符号
		sAim := findFirstMatch(cp)

		// 如果剩余的文本长度较短或者已满足条件,直接添加到 Sr
		// 判断:如果当前已经处理的文本长度加上剩余文本长度 <= 30(即不超过30个汉字)
		if len(Sr)+len(cp) <= 20 {
			Sr += cp // 将剩余的文本直接添加到 Sr
			cp = ""  // 剩余文本清空
			break    // 处理完成,退出循环
		}

		// 查找切割点,确保切割后的文本不超过 30 个汉字
		// 如果当前已经处理的文本长度小于30,并且找到一个符合条件的标点符号
		if len(Sr) < 20 && sAim != "" {
			runeCount := 0 // 汉字计数器
			index := 0     // 字符串切割的索引位置

			// 遍历字符串,统计汉字数量(按字符数统计,处理UTF-8编码)
			for index = range cp {
				// 如果已处理的汉字数量大于等于30,则停止
				if runeCount >= 20 {
					break
				}

				// 增加汉字计数
				runeCount++
			}

			// 将符合条件的文本(从开始到切割点)添加到 Sr
			Sr += cp[:index]
			// 剩余的文本部分,更新 cp
			cp = cp[index:]
			// 处理完成,退出循环
			break
		}
	}

	// 返回切割后的结果:已处理的文本和剩余的文本
	return Sr, cp
}

// 这个函数的作用是找到字符串中第一个符合标点符号的字符
func findFirstMatch(str string) string {
	// 遍历标点符号集合,检查当前文本中是否包含标点符号
	for _, match := range PunctuationMarks {
		// 如果找到符合的标点符号,则返回该符号
		if strings.Contains(str, match) {
			return match
		}

	}
	// 如果没有找到任何符合的标点符号,返回空字符串
	return ""
}

// splitPlayText 函数用于将输入文本分割成当前播放文本和剩余文本
// 参数:input - 输入的字符串
// 返回值:(string, string) - 第一个返回值是本次播放的文本,第二个返回值是剩余文本
func splitPlayText(input string) (string, string) {
	// 将输入字符串转换为 rune 切片,以正确处理中文字符
	// 如果输入长度小于等于15个字符,直接返回整个字符串作为播放文本,无剩余文本
	if len([]rune(input)) <= 15 {
		return input, ""
	}

	// 定义可用的分隔符数组
	// 包含中文标点(。,!?)和英文标点(.,!?)以及换行符
	separators := []rune{'。', '.', ',', ',', '!', '!', '?', '?', '\n'}

	// 将输入字符串转换为 rune 切片,便于按字符处理
	runes := []rune(input)

	// 记录最后一个找到的分隔符位置
	// 初始化为-1表示还未找到分隔符
	lastSepPos := -1

	// 遍历字符串中的每个字符
	for i, r := range runes {
		// 标记当前字符是否为分隔符
		isSeparator := false

		// 检查当前字符是否是分隔符
		for _, sep := range separators {
			if r == sep {
				isSeparator = true
				// 更新最后一个分隔符的位置
				lastSepPos = i
				break
			}
		}

		// 处理超过25个字符的情况
		// 如果当前位置超过25且之前找到过分隔符,从最后一个分隔符处切割
		if i >= 25 && lastSepPos != -1 {
			// 返回分隔符之前的文本(包含分隔符)作为播放文本
			// 分隔符之后的文本作为剩余文本
			return string(runes[:lastSepPos+1]), string(runes[lastSepPos+1:])
		}

		// 处理超过15个字符的情况
		// 如果当前位置超过15且当前字符是分隔符,在当前位置切割
		if i >= 15 && isSeparator {
			// 返回当前分隔符之前的文本(包含分隔符)作为播放文本
			// 分隔符之后的文本作为剩余文本
			return string(runes[:i+1]), string(runes[i+1:])
		}
	}

	// 如果遍历完整个字符串都没有找到合适的切割点
	// 返回整个字符串作为播放文本,无剩余文本
	return string(runes), ""
}

// 创建SSML内容
//func createSSML(text, voicer string) string {
//	return fmt.Sprintf("<speak><voice name=\"%s\">%s</voice></speak>", voicer, text)
//}

// 向TTS API请求音频数据
func fetchAudioToTTS(text string, voicer string, format string) ([]byte, error) {
	url := "http://52.83.116.11:13679/Say/api/ra"

	payload := "<speak xmlns=\"http://www.w3.org/2001/10/synthesis\" " +
		"xmlns:mstts=\"http://www.w3.org/2001/mstts\" " +
		"xmlns:emo=\"http://www.w3.org/2009/10/emotionml\" " +
		"version=\"1.0\" " +
		"xml:lang=\"en-US\">" +
		"<voice name=\"" + voicer + "\">" +
		"<prosody rate=\"0%\" pitch=\"0%\">" +
		text +
		"</prosody>" +
		"</voice>" +
		"</speak>"

	fmt.Println(" ========================================== ")

	fmt.Println(" payload : =>", payload)
	// 设置请求头
	req, err := http.NewRequest("POST", url, strings.NewReader(payload))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/xml")
	req.Header.Set("format", format)

	// 发送请求并获取响应
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		fmt.Println("fetchAudioToTTS err => ", err.Error())
		return nil, err
	}
	defer resp.Body.Close()

	// 读取音频流
	audioData, err := io.ReadAll(resp.Body)
	if err != nil {
		fmt.Println("读取音频流 err => ", err.Error())
		return nil, err
	}

	return audioData, nil
}

// 处理文本分段,逐段请求TTS并通过channel返回音频数据
func processTextInChunks(content string, voicer string, ch chan<- []byte) {
	for len(content) > 0 {
		// 切割文本
		segment, remaining := splitPlayText(content)
		content = remaining
		// 如果 segment 为空,直接跳过
		if len(strings.TrimSpace(segment)) == 0 && len(strings.ReplaceAll(segment, "\n", "")) == 0 {
			//continue
			break
		}
		// 创建SSML
		//ssml := createSSML(segment, voicer)
		fmt.Println("segment : => ", segment, "content : ==>", content)
		// 请求TTS接口获取音频
		audioData, err := fetchAudioToTTS(segment, voicer, format)
		if err != nil {
			fmt.Println("Error generating audio:", err)
			continue
		}
		//fmt.Println("audioData : =>", audioData)
		// 将音频数据发送到channel
		ch <- audioData

		if len(content) == 0 {
			// 音频数据传输结束
			break
		}

		// 模拟逐段播放,每段等待一段时间
		time.Sleep(3 * time.Second)
	}

	// 关闭channel
	close(ch)
}

// WebSocket连接处理函数,流式传输音频数据
func HandleAudioStream(ws *websocket.Conn, msgContent string, voicer string) {
	//ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
	//if err != nil {
	//	log.Printf("WebSocket upgrade error: %v", err)voicer
	//	return
	//}
	//defer ws.Close()

	// 创建一个带互斥锁的buffer
	audioBuffer := &AudioBuffer{}

	// 创建用于通知新数据到达的channel
	newDataChan := make(chan struct{}, 1)
	// 创建用于通知所有音频获取完成的channel
	doneChan := make(chan struct{})

	// 创建一个channel,用于接收音频数据
	audioCh := make(chan []byte)

	// 测试文本
	//content := "这是一个测试文本,包含多个标点符号,看看如何处理。流式计算引擎Flink,是大数据领域非常常用的一个计算框架和分布式处理引擎,用于在无边界和有边界数据流上进行有状态的计算。"

	// 启动处理文本切割和请求TTS的goroutine
	go processTextInChunks(msgContent, voicer, audioCh)

	// 启动获取音频的goroutine
	go FetchAudioSequentially(audioCh, audioBuffer, newDataChan, doneChan)

	// 启动发送音频的goroutine
	go SendStreamAudio(ws, audioBuffer, newDataChan, doneChan)

	// 等待WebSocket连接关闭
	for {
		if _, _, err := ws.ReadMessage(); err != nil {
			log.Printf("WebSocket read error: %v", err)
			return
		}
	}
}

// fetchAudioSequentially 负责按顺序获取音频数据
func FetchAudioSequentially(audioCh <-chan []byte, audioBuffer *AudioBuffer, newDataChan chan struct{}, doneChan chan struct{}) {
	defer close(doneChan)

	// 从audioCh获取音频数据
	for audioData := range audioCh {
		// 将获取到的音频数据写入音频缓冲区
		audioBuffer.Write(audioData)
		//fmt.Println("audioData : => ", audioData)

		// 通知有新数据可用
		select {
		case newDataChan <- struct{}{}:
		default:
		}

		// 模拟处理延时(实际场景可删除)
		time.Sleep(time.Second * 2)
	}
}

// SendStreamAudio 负责将获取到的数据流式发送到客户端
func SendStreamAudio(ws *websocket.Conn, audioBuffer *AudioBuffer, newDataChan chan struct{}, doneChan chan struct{}) {
	buffer := make([]byte, bufferSize)
	//var offset int64 = 0

	for {
		select {
		case <-newDataChan:
			// 有新数据可用,尝试读取并发送
			for {
				n, err := audioBuffer.Read(buffer)
				if err == io.EOF {
					// 当前buffer读完,等待新数据
					//fmt.Println("err => ", err.Error())
					break
				}
				if err != nil {
					log.Printf("Error reading buffer: %v", err)
					return
				}

				// 发送数据
				//fmt.Println(" ws.WriteMessage 发送数据: ==> ", buffer[:n])

				err = ws.WriteMessage(websocket.BinaryMessage, buffer[:n])
				if err != nil {
					log.Printf("Error writing to websocket: %v", err)
					return
				}

				//offset += int64(n)
			}

		case <-doneChan:
			// 所有音频获取完成,确保发送完最后的数据
			for {
				n, err := audioBuffer.Read(buffer)
				if err == io.EOF {
					return
				}
				if err != nil {
					log.Printf("Error reading final buffer: %v", err)
					return
				}

				err = ws.WriteMessage(websocket.BinaryMessage, buffer[:n])
				if err != nil {
					log.Printf("Error writing final data to websocket: %v", err)
					return
				}
			}
		}
	}
}


HTTP_TTS流式输出音频流代码示例

package service

import (
	"PsycheEpic/src/models"
	"fmt"
	"github.com/gin-gonic/gin"
	"log"
	"net/http"
	"strings"
	"time"
)

// 处理文本分段,逐段请求TTS并通过channel返回音频数据
func http_processTextInChunks(content string, voicer string, ch chan<- []byte) {
	for len(content) > 0 {
		// 切割文本
		segment, remaining := splitPlayText(content)
		content = remaining
		// 如果 segment 为空,直接跳过
		if len(strings.TrimSpace(segment)) == 0 && len(strings.ReplaceAll(segment, "\n", "")) == 0 {
			//continue
			break
		}
		// 创建SSML
		//ssml := createSSML(segment, voicer)
		fmt.Println("segment : => ", segment, "content : ==>", content)
		// 请求TTS接口获取音频
		audioData, err := fetchAudioToTTS(segment, voicer, format)
		if err != nil {
			fmt.Println("Error generating audio:", err)
			continue
		}
		//fmt.Println("audioData : =>", audioData)
		// 将音频数据发送到channel
		ch <- audioData

		if len(content) == 0 {
			// 音频数据传输结束
			ch <- nil // 使用nil来表示音频数据已经完成
			break
		}

		// 模拟逐段播放,每段等待一段时间
		time.Sleep(3 * time.Second)
	}

	// 关闭channel
	close(ch)
}

func HandleHTTPAudioStream(c *gin.Context) {

	// 设置响应头
	c.Writer.Header().Set("Content-Type", "audio/mp3")
	c.Writer.Header().Set("Transfer-Encoding", "chunked")
	// 获取 form-data 参数
	text := c.PostForm("text")
	voicer := c.PostForm("voicer")
	log.Printf("接收到TTS请求 - 文本: %s, 语音类型: %s", text, voicer)
	// 参数校验
	if text == "" || voicer == "" {
		c.JSON(http.StatusBadRequest, gin.H{"code": 0, "message": "参数 text 和 voicer 不能为空"})
		return
	}
	deviceNo := c.PostForm("deviceNo")
	if deviceNo == "" {
		c.JSON(http.StatusOK, gin.H{"code": 0, "message": "deviceNo参数不能为空"})
		return
	}
	DollInfo, err := models.GetDollUserRelationBySerialNumber(deviceNo)
	if err != nil {
		c.JSON(http.StatusOK, gin.H{"code": 0, "message": "查询玩具设备信息出错"})
		return
	}
	if DollInfo == nil {
		c.JSON(http.StatusOK, gin.H{"code": 0, "message": "未查询到设备绑定的用户信息"})
		return
	}
	// 检查设备是否当前已经连接
	if _, exists := DollChatCache[deviceNo]; !exists {
		c.JSON(http.StatusOK, gin.H{"code": 0, "message": "设备未连接或已断开"})
		return
	}

	// 创建一个无缓冲的通道来协调goroutine
	done := make(chan bool)
	// 初始化缓存区,开辟空间
	//buffer := make([]byte, bufferSize)
	// 使用CloseNotify来检测客户端连接是否关闭
	clientGone := c.Writer.CloseNotify()

	// 创建带互斥锁的音频缓冲区
	//audioBuffer := &AudioBuffer{}

	// 创建用于接收音频数据的 channel
	audioCh := make(chan []byte)

	// **启动 Goroutine 处理文本分割和请求 TTS**
	// 启动后台处理
	go func() {
		log.Println("开始处理文本并获取音频数据")
		http_processTextInChunks(text, voicer, audioCh)
	}()

	// 发送数据
	go func() {

		for {
			select {

			// 检查客户端是否已断开连接
			case <-clientGone:
				fmt.Println("客户端已断开连接")
				done <- true
				return
			default:
				// 继续处理

			// 从audioCh管道获取数据
			case audioData := <-audioCh:
				//fmt.Println("有数据... ", string(audioData))
				// 从audioCh管道获取数据
				if audioData == nil {
					// 如果接收到 nil,表示音频数据已发送完毕
					fmt.Println("数据已发送完毕")
					done <- true
					return
				}

				// 直接写入音频数据到 HTTP 响应
				_, err := c.Writer.Write(audioData)
				if err != nil {
					log.Printf("写入 HTTP 音频流出错: %v", err)
					return
				}

				// 确保数据实时发送
				if flusher, ok := c.Writer.(http.Flusher); ok {
					flusher.Flush()
				}

			}

		}

		done <- true

	}()

	// 等待所有数据处理完成或客户端断开连接
	<-done

}

;