本文介绍如何使用 Linux 开发板接入 OpenAI 的实时语音聊天接口,实现语音识别和生成。通过这种方式,你可以在 Linux 开发板上实现语音交互功能,例如语音助手、语音控制等。内容涉及 .NET 知识、Linux 音频处理、WebSocket 通信、LCD 显示等技术,适合对.NET 嵌入式音频开发感兴趣的读者学习和参考。
1. 背景
前面我们介绍如何使用纯前端技术实现接入 OpenAI 的实时语音聊天接口,也了解如何使用 .NET 在 Linux 上实现基础的语音录制和播放功能并可以驱动 LCD 屏幕显示。如今万事俱备,只欠东风,我们可以将这些结合起来,实现在 Linux 开发板上接入实时语音聊天功能。
建议在本文之前先回顾之前的文章,以便更好地理解本文的内容:
这里我们还是使用 Luckfox 开发板为例,毕竟一百多的价格并直接板载了麦克风和扬声器,非常适合这种应用场景。
通过前面的文章,我想你已经对 Luckfox 开发板有了一定的了解,并做好了相关的准备工作。接下来我们将一步步实现在 Linux 开发板上接入 OpenAI 的实时语音聊天功能。
2. 项目架构
这个项目使用了 .NET 的依赖注入和配置系统,主要通过 IHostBuilder
来构建和配置应用程序,这里我们需要注册三个主要服务:AudioService
、WebSocketService
和 LcdService
。
2.1 IHostBuilder
IHostBuilder
是 .NET 中用于构建通用主机的接口,提供了配置和依赖注入的基础。这个项目中使用 Host.CreateDefaultBuilder
方法来创建一个默认的主机构建器,并通过链式调用进行配置。
首先,我们进行基本的应用程序配置:
.ConfigureAppConfiguration((hostingContext, config) =>
{
var env = hostingContext.HostingEnvironment;
config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true);
config.AddEnvironmentVariables();
})
hostingContext.HostingEnvironment
: 获取当前的主机环境(如开发、生产等)。config.AddJsonFile
: 加载应用程序配置文件appsettings.json
和环境特定的配置文件appsettings.{env.EnvironmentName}.json
,并允许在文件更改时重新加载配置。config.AddEnvironmentVariables
: 加载环境变量配置。
接着,注册服务和配置选项:
.ConfigureServices((context, services) =>
{
services.Configure<RealtimeAPIOptions>(context.Configuration.GetSection("RealtimeAPI"));
services.Configure<SessionUpdateOptions>(context.Configuration.GetSection("SessionUpdate"));
services.Configure<AudioSettings>(context.Configuration.GetSection("AudioSettings"));
services.Configure<LcdSettings>(context.Configuration.GetSection("LcdSettings"));
services.AddSingleton<AudioService>();
services.AddSingleton<WebSocketService>();
services.AddSingleton<LcdService>();
services.AddHostedService<Worker>();
})
services.Configure<TOptions>
: 从配置中绑定特定部分到选项类,例如RealtimeAPIOptions
、SessionUpdateOptions
、AudioSettings
和LcdSettings
。services.AddSingleton<TService>
: 注册单例服务,这些服务在应用程序生命周期内只创建一次。例如AudioService
、WebSocketService
和LcdService
。services.AddHostedService<Worker>
: 注册一个托管服务Worker
,它实现了IHostedService
接口,用于在应用程序启动和停止时执行后台任务。
2.2 主要服务
WebSocketService
负责处理 WebSocket 连接和通信。它包括以下功能:
- 建立和管理 WebSocket 连接。
- 发送和接收消息。
- 处理 WebSocket 事件。
AudioService
负责处理与音频相关的操作。它包括以下功能:
- 录制和播放音频。
- 管理音频文件。
- 与音频设备进行交互。
LcdService
负责与 LCD 显示屏进行交互。它包括以下功能:
- 显示文本或图像。
- 更新显示内容。
- 管理显示屏状态。
3. 实现
接下来我们将一步步实现这个项目。在这三个服务中,WebSocketService
是核心服务,它负责与 OpenAI 的实时语音聊天接口进行通信。首先,我们将实现 WebSocketService
,然后再实现 AudioService
和 LcdService
,最后在 Worker
中调用这些服务。如果不接入显示屏,可以不实现 LcdService
,这个服务是可选的。
3.1 WebSocketService
WebSocketService
是一个用于管理 WebSocket 连接的服务,主要功能包括:
- 建立和管理 WebSocket 连接。
- 发送和接收消息。
- 处理连接、断开连接和消息接收事件。
该服务通过依赖注入方式获取配置选项和日志记录器,并提供了几个事件供外部订阅,以便在连接状态变化和消息接收时进行处理。
- OnMessageReceived: 当收到消息时触发,传递消息内容。
- OnConnected: 当连接成功时触发。
- OnDisconnected: 当连接断开时触发。
需要注意的是,因为 OpenAI 的实时语音聊天接口返回的音频数据是经过 Base64 编码的 PCM 数据,数据长度较大,所以需要考虑数据的处理,接收完整的音频数据。以下是 WebSocketService
中 ReceiveLoopAsync
的核心代码,用于在 WebSocket
连接打开时持续接收消息。它通过一个循环不断地从 WebSocket
读取数据,并在收到完整消息时触发 OnMessageReceived
事件:
private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
{
var buffer = new byte[1024 * 4];
var messageBuffer = new List<byte>();
while (_client.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
var result = await _client.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
{
await _client.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", cancellationToken);
_logger.LogInformation("WebSocket closed!");
await OnDisconnected.Invoke();
}
else
{
messageBuffer.AddRange(buffer.Take(result.Count));
if (result.EndOfMessage)
{
var message = Encoding.UTF8.GetString(messageBuffer.ToArray());
await OnMessageReceived.Invoke(message);
_logger.LogDebug($"Received: {message}");
messageBuffer.Clear();
}
}
}
}
3.2 AudioService
AudioService
是一个用于处理音频操作的服务,这里我们使用的还是 Alsa.Net
驱动,通过 libasound2-dev
包提供的库来实现音频录制和播放功能。因为该包缺乏维护,使用起来可能会有一些问题,但是对于基本的音频操作还是可以满足需求的。
为了实现流式音频录制我们需要在 AudioService
中实现 StartRecordingAsync
、SendPackets
和 StopRecording
方法。StartRecordingAsync
方法用于启动音频录制,处理录制过程中接收到的音频数据,并将数据包加入队列;SendPackets
方法用于从队列中提取音频数据包,合并为一个较大的数据包,并触发 OnAudioDataAvailable
事件;StopRecording
方法用于停止音频录制,并触发 OnRecordingStopped
事件。
public async Task StartRecordingAsync()
{
if (_isRecording)
{
return;
}
_cancellationTokenSourceRecording = new CancellationTokenSource();
_isRecording = true;
await Task.Run(() => _alsaDevice.Record(async (data) =>
{
if (OnAudioDataAvailable != null)
{
if (!hasDiscardedWavHeader)
{
hasDiscardedWavHeader = true;
return;
}
if (!_isRecording)
{
return;
}
data = ConvertStereoToMono(data); // 将数据转换为单声道
_packetQueue.Enqueue(data); // 将数据包加入队列
if (_packetQueue.Count >= PacketThreshold)
{
await SendPackets(); // 当队列中的数据包数量达到阈值时发送数据包
}
}
}, _cancellationTokenSourceRecording.Token), _cancellationTokenSourceRecording.Token);
}
需要注意的是,Alsa.Net
库录制的音频会包含 WAV 头数据,需要在处理音频数据时将 WAV 头数据丢弃,并将音频数据转换为单声道后加入队列。当队列中的数据包数量达到阈值时,调用 SendPackets
方法发送数据包:
private async Task SendPackets()
{
if (!_isRecording)
{
return;
}
int totalSize = PacketThreshold * PacketSize;
byte[] combinedPacket = new byte[totalSize];
int offset = 0;
while (_packetQueue.Count > 0 && offset + PacketSize <= totalSize)
{
byte[] packet = _packetQueue.Dequeue();
if (packet.Length == PacketSize)
{
Buffer.BlockCopy(packet, 0, combinedPacket, offset, PacketSize);
offset += PacketSize;
}
}
if (OnAudioDataAvailable != null && offset > 0)
{
await OnAudioDataAvailable(combinedPacket);
}
}
除了录音外,AudioService
还提供了播放音频的功能,通过 PlayAudioAsync(byte[] pcmData)
方法实现。因为音频处理有很多坑要讲,这里我们后面再详细说明。
3.3 LcdService
这个服务不是主要的,只是用来显示一些信息,这里我们主要用来展示录音状态和服务端返回的文本信息。具体可以参考之前的文章,并查看本项目的源码。
4. 音频处理
总的来说,这一节才是重头戏。当我以轻松的心态开启这个项目时,还未曾料到音频预处理会成为吞噬数百小时的"技术沼泽"。毕竟前面已做了充足的准备。但谁能想到,这看似简单的声波数据在调试接入过程中显露出狰狞面目:直流偏移、高通滤波器(HPF)、降噪、回声抑制(AEC)、启动噪声等等,却让我疲于应对。
相比之下,顶层项目反而显得简单许多。虽然在网页版中也遇到了一些波折,但总体来说还是相对容易的。无需考虑这些复杂的问题,只需直接调用接口即可,那些复杂的配置和处理都由硬件默认开启并处理了。但在嵌入式设备上,这些问题就需要我们自己来解决了。当然,生态好的话,也可以找到一堆现成的解决方案,开箱即用。
4.1 音频格式问题
在接入 OpenAI 的实时语音聊天接口时,我们需要将音频数据转换为特定的格式,以便发送给服务器进行处理。OpenAI 的实时语音聊天接口要求音频数据为 16位深度,单声道的 PCM 格式,采样率是 24kHz。同时根据文档,音频也可以是压缩的 G.711 音频。
这里我们直接选择使用未压缩的 PCM 音频格式。并且不能含有音频头信息,还需要为单声道,否则会导致服务器无法正确解析音频数据。因为我测试的设备在使用Alsa.Net
库录制时,使用单声道录制会报错,并且录制时会默认包含音频头信息,所以在前面的代码中做了去除音频头和双声道转单声道的处理。如果音频格式存在问题,服务端也会出现如下错误 Invalid 'audio'. Expected base64-encoded audio bytes (mono PCM16 at 24kHz) but got an invalid value.
:
{
"type":"error",
"event_id":"event_BDv6vl8eCfBloxsnlJ74U",
"error":{
"type":"invalid_request_error",
"code":"invalid_value",
"message":"Invalid 'audio'. Expected base64-encoded audio bytes (mono PCM16 at 24kHz) but got an invalid value.",
"param":"audio.audio",
"event_id":null
}
}
那么,在这个时候调试就很重要,我们可以使用 Python 直接编写一个服务,模拟 OpenAI 的服务,用于接收音频数据并保存到本地,以便调试。这里我们可以使用 websockets
库来实现 WebSocket 服务:
import asyncio
import websockets
import json
import base64
import wave
import numpy as np
buffer = []
save_count = 0
async def process_audio_data():
global buffer
global save_count
if len(buffer) >= 50:
print("Processing audio data...")
# 组合缓冲区中的数据
audio_data = b''.join(buffer)
# 存储一个buffer 测试
with open(f'data/output{save_count}.pcm', 'wb') as f:
f.write(audio_data)
buffer = [] # 清空缓冲区
# 将 PCM 数据写入 WAV 文件
with wave.open(f'data/output{save_count}.wav', 'wb') as wf:
wf.setnchannels(1) # 单声道
wf.setsampwidth(2) # 16位
wf.setframerate(24000) # 24000 Hz
wf.writeframes(audio_data)
save_count += 1
print("Audio data saved to output.wav")
async def handler(websocket):
global buffer
print("Client connected")
async def ping():
while True:
try:
await websocket.send(json.dumps({"type": "ping"}))
await asyncio.sleep(10) # 每10秒发送一次ping
except websockets.ConnectionClosed:
break
asyncio.create_task(ping())
async for message in websocket:
# 记录接收到的数据到文件
with open('data/received_data.txt', 'a') as f:
f.write(message + '\n')
data = json.loads(message)
if data['type'] == 'input_audio_buffer.append':
print("Received audio buffer data")
pcm_data = base64.b64decode(data['audio'])
buffer.append(pcm_data)
await process_audio_data()
print("Client disconnected")
async def main():
print("Starting server...")
async with websockets.serve(handler, "0.0.0.0", 8765):
print("Server started on ws://0.0.0.0:8765")
await asyncio.Future() # run forever
if __name__ == "__main__":
asyncio.run(main())
同时在测试时,我们也可以录制好符合条件的 PCM 音频文件,通过接口直接发送,用于排除其他问题。这也是我在调试过程中使用的方法,因为这一路走来确实遇到了很多问题。
4.2 直流偏移
解决好格式后,事情并没有变的顺利,反而更加扑朔迷离,因为服务端在发送音频数据后并无任何响应,这也直接让我挠头,放弃了一段时间。直到比对电脑录制的PCM音频和发往服务端的音频数据时,才发现了问题所在:
通过上面两个音频波形的对比,可以明显看到区别,左边是开发板录制后发往服务端的音频波形,右边是电脑录制的音频波形。几番求证后,认识了这个新的名词:直流偏移。直流偏移是指音频信号中存在一个恒定的直流成分,导致波形整体向上或向下偏移。这种直流偏移会导致音频数据的均值不为零,影响音频的质量和处理。虽然我在听起来并没有明显的问题,但是服务端的处理却无法正确解析这种音频数据。也有可能是服务端的语音检测无法正确识别静音,无法自动触发生成响应,当然这个只是猜测。
不过,这个问题也好办,在使用 alsamixer
进行音频配置时,就发现了有相关 HPF(High Pass Filter)的选项,这个选项可以用来去除音频信号中的直流偏移。
在 alsamixer
的 UI 界面中,可以通过 HPF
选项来开启高通滤波器,去除音频信号中的低频成分,从而消除直流偏移。这里我们也可以直接通过 amixer
命令来设置 HPF 选项:
amixer cset name='ADC HPF Cut-off' 'On'
不同的设备可能存在差异,具体的可自行尝试。这样处理后,再次录制音频并发送到服务端,服务端就可以正确解析音频数据了。
4.3 回声抑制
在实时语音聊天中,回声抑制是一个重要的技术,用于消除扬声器输出的音频信号在麦克风中产生的回声。回声抑制可以提高语音通话的质量,减少回声和杂音,使通话更加清晰和稳定。当然,这个也是一个很复杂的技术,如果你需要了解更多,可以参考相关资料,《一文读懂回声消除(AEC)》。
在本项目中,出现的回声问题主要是由于扬声器输出的音频信号在麦克风中产生的回声。这就会导致询问了一个问题后,服务端会不断的自问自答,不断的自己打断自己的推理返回,这显然不是我们想要的。所以,我们需要在录制音频时,对音频数据进行回声抑制处理。
不过,在这个项目中,我并没有去进行回声抑制处理,而是调整了逻辑,在播放音频时,将麦克风静音,这样就不会出现回声问题了。当然,也可以进行调整麦克风和扬声器的位置,减少回声的产生。后续有时间,可以尝试进行回声抑制处理。
4.4 启动噪声
毕竟Alsa.Net
库好久没有更新了,而且在使用过程中也是问题不断,特别是这个声道的问题,录制和播放单声道音频都会报错。这就需要我们额外占用更多的资源,加工一遍音频数据。包括流式录制和流式播放音频都需要自行实现,这也是一个比较复杂的问题。
在音频播放中,刚开始的版本也是实现了流式播放:
/// <summary>
/// 播放队列
/// </summary>
private readonly ConcurrentQueue<byte[]> _playbackQueue = new ConcurrentQueue<byte[]>();
private bool _isPlaying = false;
public async Task PlayAudioAsync(byte[] pcmData)
{
var monoData = ConvertMonoToStereo(pcmData);
_playbackQueue.Enqueue(monoData);
if (!_isPlaying)
{
_isPlaying = true;
await Task.Run(() => ProcessPlaybackQueue());
}
}
private void ProcessPlaybackQueue()
{
while (_playbackQueue.TryDequeue(out var pcmData))
{
Play(pcmData);
}
_isPlaying = false;
OnPlaybackStopped?.Invoke();
}
private void Play(byte[] pcmData)
{
var wavData = CreateWavHeader(24000, 16, 2).Concat(pcmData).ToArray();
//using var alsaDevice = AlsaDeviceBuilder.Create(_settings);
_alsaDevice.Play(new MemoryStream(wavData), _cancellationTokenSourcePlayback.Token);
}
private void StopPlayback()
{
_cancellationTokenSourcePlayback?.Cancel();
_cancellationTokenSourcePlayback = new CancellationTokenSource();
_isPlaying = false;
_playbackQueue.Clear();
}
但是在播放音频时,刚开始播放时会出现刺耳的高音异响,这是由扬声器在刚开始播放音频时出现的杂音引起的。这种噪音通常是短暂的,持续时间很短,但对音频播放体验产生负面影响。这种噪音通常被称为“启动噪声”或“开机噪声”(Power-on Noise)。这种噪音可能是由于电路在启动时电压不稳定、瞬态电流变化或扬声器驱动电路的初始化过程引起的。
而服务器的音频是流式传输的一段一段的,流式播放就会导致这个启动噪音一只出现。所以,为了解决这个问题,我将音频数据保存到文件,然后再播放:
public async Task SaveAudio(string audioId, byte[] pcmData)
{
string filePath = $"audio/{audioId}.wav";
var monoData = ConvertMonoToStereo(pcmData);
byte[] wavData;
if (!File.Exists(filePath))
{
byte[] wavHeader = CreateWavHeader(24000, 16, 2);
wavData = wavHeader.Concat(monoData).ToArray();
await File.WriteAllBytesAsync(filePath, wavData);
}
else
{
using (var fileStream = new FileStream(filePath, FileMode.Append, FileAccess.Write))
{
await fileStream.WriteAsync(monoData, 0, monoData.Length);
}
}
}
public async Task PlayAudio(string audioId)
{
string filePath = $"audio/{audioId}.wav";
if (!File.Exists(filePath))
{
return;
}
await Task.Run(() =>{
//using var alsaDevice = AlsaDeviceBuilder.Create(_settings);
_alsaDevice.Play(filePath, _cancellationTokenSourcePlayback.Token);
OnPlaybackStopped?.Invoke();
}, _cancellationTokenSourcePlayback.Token);
}
这样就不会出现这个问题了。当然,这样也会增加一些延迟,但是对于这个项目来说,这个延迟是可以接受的。也算是一种折中的方案。
5. 总结
在这个项目中,主要的难点还是音频的处理。加之硬件设备的限制,使得整个项目变得复杂起来。还没有找到合适的在 Linux 使用的靠谱的音频库,这也导致了很多问题。不过,这也是技术的魅力所在,不断的解决问题,不断的学习,才能不断进步。等后续有时间,可以尝试其他的音频库,如 PortAudioSharp2,或者优化一下这个音频库,解决流式播放等问题。
项目开源地址:https://github.com/sangyuxiaowu/EdgeVoice?wt.mc_id=DT-MVP-5005195,欢迎大家一起来完善这个项目。比如:接入小智,增加.NET 服务端实现本地语音大模型,增加摄像头,语音唤醒等功能,这块板子的可玩性还是很高的。
回首这段经历,那些曾以为棘手的难题,不过是技术探索路上必经的锤炼。在这个项目中,学到了很多关于音频处理的知识,也深刻体会到了技术的无穷魅力。回望来时路,虽然曲折,但每一步都值得。不过是,些许风霜罢了。