背景 在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
}
Copy 接下来是将它转换为文字。这一步选择有很多,可以用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
}
Copy 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 ()
}
Copy 起了两个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 ())
}
}
}
Copy 其中开始播放时,需要收集到一些数据才能开始。初始化后立即播放有时会没有声音,未来可能会找找有没更好用的库:) 暂时通过上面一些补丁算是稳定能用。
TUI装修美化一下 基本功能有了,最开始我是命令行的方式,想着毕竟要写个文章介绍一下,就用个最简单的TUI包装一下吧,临时学习了一下bubbletea
库的使用。有小朋友和我说这个库有点重杂,刚开始看是这样的,当把遇到的几个问题和BUG修改完,似乎这个库也就是轻量、没那么复杂了:) 改BUG果然是学习东西的好门路!
基本UI 想着有些东西可能会调整着玩的:如模型、音色、情感等,就将它们放在左边的设置里了。当然这个列表还可以不断扩展,我这里仅是做个演示就没塞太多选项。
然后是聊天历史,我们虽然是语音交流,文字也要同步展示出来,所以就给了个地方显示一下,也可以顺便看看上下文都是些什么。
最后,输入中我们想着有时不方便语音说话,所以文字输入和语音输入同时支持。要再画个UI单独是个录音等,我又不想这么搞。我居然创造性的让这个文本框在点击它时进入录音状态,再次点击取消录音。同时这个文本框聚焦后能输入文字,就这么一石二鸟了,哈哈!想不到吧:)
以下是最后的UI,也是蛮粗糙的,但基本够用就行吧。
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 }
}
// 省略
Copy 然后在其它线程中响应它即可。这期间为了处理鼠标的点击事件(对的,要自己封装去判断点到了哪个控件),发现边框等会占用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 ()
}
Copy 后记 上述的实现已经在GitHub中开源了,需要请查看talk-with-ai ,如果对你有用,欢迎Star。接下来的文章,可能会聊一下如何复刻你的声音,然后在这里用上。听听自己声音的回答到底是惊喜还是恐怖,你说呢?
EOF
我是个爱折腾技术的工程师,也乐于分享。欢迎点赞、关注、分享,更欢迎一起探讨技术问题,共同学习,共同进步。为了获得更及时的文章推送,欢迎关注我的公众号:
扫码关注公众号
Preview: