Featured image of post 自制实时AI语音对话

自制实时AI语音对话

背景

在GPT刚出来时,恰逢家里小娃经常要嚷嚷着听故事,讲一个什么什么的故事,可是苦于想象力的匮乏,要胡编一个带有主题思想的故事还挺难。作为程序员老爸,那时我就打算给它造一个AI爸爸出来。所以,这个文章算是这过程中的副产品吧。

我在写着写着,拖着拖着,GPT-4o演示出来了,那语音对话等能力加上及时响应性,一度我都打算中止相关开发和验证了。可是没想到,OpenAI他们也拖着拖着,一直没对外放出这块能力,居然又熬到了我快赶上了:D

我们基础的框架是实时对话能力,然后为了让这个AI爸爸更像样,就需要基于自己的声音训练一个模型。第一部分是一个比较基础的各种能力的调用,关注点在实时性上。第二点略难,但所幸已经有比较多种的开源实现了,只要整合即可。这篇文章先介绍第一部分。

实时对话的几步

我希望通过语音或文字输入和AI交流,背后的AI可自行选择。显然传统的拿到回答再通过语音合成,再播放的话,这延迟就太大了,我们需要在全程利用实时流式处理,以此提升响应速度,最后基本可以在1-2s内语音回答。整体的效果如下:

UI

以下按执行顺序,分各步骤聊一下。

AI交互过程:语音输入

一般来说,我们通过语音输入的内容不会太长。尽管我们称之为实时,但这一步并非真正实时的。当前的AI也不能将prompt分多次上传呀?所以借助于录音之后,我们进行一次语音识别。当前(24年7月)大模型的多模态还不支持语音输入,故语音识别自然是免不了的。 我们先说录制,我是MacOS机器,所以直接调起了sox程序实现内容的录制。我们只需要几句话就封装了一个录音机:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

type Recorder struct {
	buf bytes.Buffer
	cmd *exec.Cmd
}

func NewRecorder() *Recorder {
	return &Recorder{}
}

func (r *Recorder) Start() {
	r.buf.Reset()
	r.cmd = exec.Command("sox", "-d", "-t", "wav", "-")
	r.cmd.Stdout = &r.buf

	err := r.cmd.Start()
	if err != nil {
		log.Fatal(err)
	}
}

// Stop recording
func (r *Recorder) Stop() {
	err := r.cmd.Process.Signal(os.Interrupt)
	if err != nil {
		log.Fatal(err)
	}

	// Wait for the recording process to finish
	err = r.cmd.Wait()
	if err != nil {
		log.Fatal(err)
	}

	log.Debugf("Recording stopped. recorded %d bytes", r.buf.Len())
}

func (r *Recorder) Buffer() *bytes.Buffer {
	return &r.buf
}

接下来是将它转换为文字。这一步选择有很多,可以用OpenAI的whisper,我正好发生腾讯云有免费送很多的token,就用它的ASR功能了。已经有现成的SDK可调,几行代码即可搞定:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 将音频内容转为文本返回,出错返回err
func (a *ASRClient) ToVoice(fileType string, fileContents []byte) (string, error) {
	request := asr.NewSentenceRecognitionRequest()

	// 设置上传本地音频文件
	request.SourceType = common.Uint64Ptr(1)
	request.VoiceFormat = common.StringPtr(fileType)
	request.EngSerViceType = common.StringPtr("16k_zh")

	// 将buf的内容base64编码后设置给request.Data
	d64 := base64.StdEncoding.EncodeToString(fileContents)
	request.Data = common.StringPtr(d64)
	request.DataLen = common.Int64Ptr(int64(len(d64)))

	response, err := a.client.SentenceRecognition(request)
	if err != nil {
		return "", fmt.Errorf("fileType:%v, len:%v, err:%w", fileType, len(fileContents), err)
	}

	return *response.Response.Result, nil
}

AI交互过程:流式输出

我们知道AI的输出有流式和非流式,而我们后面还要将文字内容合成为语音呢,为了更快速的有响应,我们必然需要使用流式输出。然后可以一边将输出传递给另一个线程去做语音合成。

流式输出没啥可讲的,但AI交互这一块,还是要再提一下之前文章介绍过的一站式多模型管理:One API实用指南。最开始我实现了多种模型的支持,后面接触到它后,果断将所有代码都移除了,那是人家做的事情,我这重复劳动就没意义了。

实时语音转换

有了比较实时的结果后,我们一边读出来,一边交给某个地方去合成(当然未来会是在本地模型或自己搭建的服务上,毕竟我是想用自己声音来讲故事的嘛)。我继续使用腾讯云的语音合成能力,非实时的方式也试过,调用后中间等待要花几秒时间,虽然说如果内容比较多时,刚开始的几秒还好,但是,咱发现有实时合成,那必须得上啊。

虽然有实时接口,但是上传并不支持持续数据流,只是下行实时,合成一部分语音就提前下发,于是我们得将要转换的内容拆分一下,我以一句话的句号来分段,这样一段段上传,然后将合成的内容提前播放,正好可以用播放掩盖掉后面合成的用时。 核心代码也很简单:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74

// StreamTTS 语音合成
// 读取textChan中的数据,将它以。分割,然后合成语音
func StreamTTS(voiceType int64, emotionCategory string, textChan chan string, audioChan chan []byte) {
	var buffer strings.Builder

	var wg sync.WaitGroup
	wg.Add(1)

	s := tts.NewRealTimeSpeechSynthesizer(int64(appId), secretId, secretKey, voiceType, emotionCategory, speed)

	sentenceChan := make(chan string)
	// 启动一个 goroutine 来处理语音转换, 这样才能按顺序
	go func() {
		defer wg.Done()
		index := 1
		for sentence := range sentenceChan {
			log.Debug("----------------------------------")
			log.Debugf("正在转换第[%d]段语音中,文字内容为:%s ", index, sentence)
			s.Run(sentence, audioChan)
			index++
			log.Debug("----------------------------------")
		}
		log.Info("**语音转换全部结束!!**")
	}()

	index := 1
	for {
		select {
		case resp, ok := <-textChan:
			if !ok {
				log.Debugf("TextChan closed, buf len:%d", buffer.Len())
				// Channel 已关闭
				if buffer.Len() > 0 {
					// 发送句子到通道
					sentenceChan <- strings.TrimSpace(buffer.String())
				}
				goto END
			}

			// log.Debugf("Speech recv [%q]", resp)
			buffer.WriteString(resp)

			// 按句号分割句子
			content := buffer.String()
			sentences := strings.Split(content, "。")

			// 重置 buffer
			buffer.Reset()

			for i, sentence := range sentences {
				sentence = strings.TrimSpace(sentence)
				if sentence == "" {
					continue
				}

				if i == len(sentences)-1 && !strings.HasSuffix(content, "。") {
					// 最后一个句子可能是不完整的,保存到 buffer 中
					buffer.WriteString(sentence)
				} else {
					sentenceChan <- sentence + "。"
					// log.Debugf("发送第[%d]句子到sentenceChan:%s", index, sentence)
					index++
				}
			}

		}
	}
END:
	close(sentenceChan)
	log.Debug("sentenceChan closed")

	wg.Wait()
}

起了两个goroutine,一个做分段,一个做合成。 发现腾讯云的语音合成音色挺多的,有小女孩的童真声音,也有粤语、四川话等,试用了还蛮有意思。同时还有情感的描述,学习到语音合成也有它的语法,可以通过在文字中做一些标注,使用不同的声音和不同的情感等。想着如果一段话,借助AI帮标注出来,再让它合成或许情感会更丰富一些。

流式语音播放

声音多数都是流媒体传播的,所以不少播放器都是支持流式播放的。我让声音合成返回了mp3,然后借助于ebitengine/oto的库完成播放工作,这个库有点小问题,播放有时会卡住不播,估计是某种并发下的异常情况没处理好,稍微walk around了一下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

func (p *MyPlayer) Play() {
	log.Debug("正在调用播放器来播放语音")
	go p.readFromStream()

	for {
		if p.readFinished || p.buffer.Len() >= minDataSize {
			log.Debugf("已经收取足够语音数据,正在初始化解码器, readFinished:%v, buf len:%d", p.readFinished, p.buffer.Len())
			// 确保播放器在播放前初始化
			if p.player != nil {
				log.Debug("播放器已经存在,关闭现有播放器")
				p.player.Close()
			}

			p.initializePlayer()

			if p.player != nil && !p.player.IsPlaying() {
				log.Debug("未在播放中,调用播放器来播放语音, Play!")
				time.Sleep(500 * time.Millisecond)
				p.player.Play()
			}

			if p.player != nil && p.player.IsPlaying() {
				log.Debug("播放器已经进入Playing")
				break
			}

			log.Debug("未在播放中,将会重置播放器!")
		}

		time.Sleep(100 * time.Millisecond)
	}
}

func (p *MyPlayer) readFromStream() {
	for {
		select {
		case data, ok := <-p.audioStream:
			if !ok {
				// Channel is closed, stop reading
				log.Warnf("audioStream closed? 将退出播放器")
				p.readFinished = true
				return
			}
			p.buffer.Write(data)
			log.Debugf("收取语音数据:%d, 剩余长度:%d", len(data), p.buffer.Len())
		}
	}
}

其中开始播放时,需要收集到一些数据才能开始。初始化后立即播放有时会没有声音,未来可能会找找有没更好用的库:) 暂时通过上面一些补丁算是稳定能用。

TUI装修美化一下

基本功能有了,最开始我是命令行的方式,想着毕竟要写个文章介绍一下,就用个最简单的TUI包装一下吧,临时学习了一下bubbletea库的使用。有小朋友和我说这个库有点重杂,刚开始看是这样的,当把遇到的几个问题和BUG修改完,似乎这个库也就是轻量、没那么复杂了:) 改BUG果然是学习东西的好门路!

基本UI

想着有些东西可能会调整着玩的:如模型、音色、情感等,就将它们放在左边的设置里了。当然这个列表还可以不断扩展,我这里仅是做个演示就没塞太多选项。 然后是聊天历史,我们虽然是语音交流,文字也要同步展示出来,所以就给了个地方显示一下,也可以顺便看看上下文都是些什么。 最后,输入中我们想着有时不方便语音说话,所以文字输入和语音输入同时支持。要再画个UI单独是个录音等,我又不想这么搞。我居然创造性的让这个文本框在点击它时进入录音状态,再次点击取消录音。同时这个文本框聚焦后能输入文字,就这么一石二鸟了,哈哈!想不到吧:)

以下是最后的UI,也是蛮粗糙的,但基本够用就行吧。 TUI

UI和逻辑分离

最开始随便在UI中调起一些逻辑,那真是个灾难,各种让UI无响应等,于是乎,学习到了bubbletea的消息处理机制。我们只需要简单的定义一些消息,将触发的一些动作作为一种行为转发出去即可。有点像我们过往Windows编程中的事件响应。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  //... 省略
	case "enter":
			switch m.currentFocus {
			case 0:
				selectedModel := m.modelList.SelectedItem().(item)
				m.notificationCh <- fmt.Sprintf("选择了模型: %s", selectedModel.Title())
				m.eventChan <- Event{Type: "model", Payload: selectedModel.Title()}
			case 1:
				selectedTone := m.toneList.SelectedItem().(item)
				m.notificationCh <- fmt.Sprintf("选择了音色: %s", selectedTone.Title())
				m.eventChan <- Event{Type: "tone", Payload: selectedTone.Title()}
			case 2:
				selectedEmotion := m.emotionList.SelectedItem().(item)
				m.notificationCh <- fmt.Sprintf("选择了情感: %s", selectedEmotion.Title())
				m.eventChan <- Event{Type: "emotion", Payload: selectedEmotion.Title()}
			case 3:
				log.Debug("选择了历史记录框")
				m.notificationCh <- "选择了历史记录"
			case 4:
				question := m.questionInput.Value()
				log.Debug("问题输入完毕", question)
				m.questionInput.SetValue("")
				m.notificationCh <- fmt.Sprintf("输入了问题: %s", question)
				m.eventChan <- Event{Type: "question", Payload: question}
			}
  // 省略

然后在其它线程中响应它即可。这期间为了处理鼠标的点击事件(对的,要自己封装去判断点到了哪个控件),发现边框等会占用1个字符,发现屏幕的宽度和高度是以字符数量来计算的。在我的Macbook Air屏幕上,它只有178x48大小。所以建议像我一样,手绘一个UI布局好好计算一下。整个UI为了让窗口自适应,需要比较好的响应tea.WindowSizeMsg事件,并且让我们的设置都是一个相对值。

中文字宽问题

在处理历史记录的展示过程中,为了让排版稍好看点,我们需要根据能显示的长度对结果的文字做重排。突然发现这也是个技术活,最开始是有些库只支持英文,对中文或其它文字长度计算有误。后面我基于go-runewidth库封装了下计算逻辑,这样中文+英文等计算宽度稍好一点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// WrapWords 使用mattn/go-runewidth库来精确计算字符宽度
func WrapWords(s string, maxWidth int) string {
	var builder strings.Builder
	currentWidth := 0
	for _, r := range s {
		charWidth := runewidth.RuneWidth(r)
		if r == '\n' {
			currentWidth = 0
		}
		if currentWidth+charWidth > maxWidth {
			builder.WriteString("\n")
			currentWidth = 0
		}
		builder.WriteRune(r)
		currentWidth += charWidth
	}
	return builder.String()
}

后记

上述的实现已经在GitHub中开源了,需要请查看talk-with-ai,如果对你有用,欢迎Star。接下来的文章,可能会聊一下如何复刻你的声音,然后在这里用上。听听自己声音的回答到底是惊喜还是恐怖,你说呢?

EOF

我是个爱折腾技术的工程师,也乐于分享。欢迎点赞、关注、分享,更欢迎一起探讨技术问题,共同学习,共同进步。为了获得更及时的文章推送,欢迎关注我的公众号:

扫码关注公众号