概要
现在想分析构成一个语音助手终端所需要完成所需要的资源和准备。
首先,需要确定要实现的功能。
功能
- 语音识别:解析用户输入的语音内容。
- 语音播报:播放合成语音输出。
- 语义理解:理解语音内容中的语义, 通过语音进行智能对话。
- 按钮控制:支持通过按钮控制交互。
- 屏幕显示:将相关信息显示在屏幕上。
方案构型
为了实现所需要的功能,需要根据已有的资料进行分析,选择什么样子的实现方案。
首先为了完成语音识别, 设备需要使用麦克风。
为了进行语音播报,设备需要有扬声器。
为了进行语义理解,这里直接接入大语言模型。
为了进行按钮控制,设备需要有按钮。
为了实现屏幕显示,设备需要有屏幕外设。
而为了将上面的功能构组合成一个整体,考虑到便携性,可以使用嵌入式设备来进行开发。
嵌入式最重要的主控部分,根据类型和官方SDK可以运行不同类型的系统,考虑到开发和维护方便以及功能性验证,这里选择了 Linux系统为基础来进行开发,依赖Linux系统的网络和丰富的API和库,可以方便的进行功能验证。
因为市面上有现成的开发板可以用于快速验证,这里选择了SoC为R329
的 MaixII-Sense
继续
硬件资料
来自MaixII-Sense
官方的设计资料:
Sipeed_MAIXSense_SCH_1.0
Sipeed_MAIX-II-A_SCH_1.0
Sipeed_MAIXSense_SensorBoard_SCH_1.0
设备成品预览图如下:
软件模块
考虑到代码兼容性问题,基于 Linux
使用 Python
+ Shell
+ C/C++驱动
进行开发
Armbain 系统源码
源码: sipeed/linux at r329-wip (github.com)
编译: R329主线armbian内核,系统,驱动开发方法 - Sipeed Wiki
烧录: 系统烧录 - Sipeed Wiki
音频模块
源码:linux/sound at r329-wip · sipeed/linux (github.com)
文字转语音
# 安装文字转语音工具
sudo apt-get install -y Python3 Python3-pip
pip3 install edge-tts
echo '
你好
' > /tmp/speek.txt
# 文字转语音
edge-tts -f /tmp/speek.txt --write-media /tmp/audio.mp3 -v zh-CN-XiaoxiaoNeural
语音播放
# 安装 mplayer
sudo apt-get install -y mplayer
# 播放音频
sudo mplayer audio.mp3
录音
sudo arecord -D "hw:0,0" -f cd -c 1 -r 44100 -d 3 /tmp/record.wav
语音转文字
代码: aimi_board/azure_api/init.py at main · observerkei/aimi_board (github.com)
语音转文字也有本地模型训练运行的方式进行识别,考虑到片上资源的稀缺性和识别的准确率,这里采用azure
云端协作的方式实现,官方提供了使用的API文档: microsoft-cognitiveservices-speech-sdk package | Microsoft Learn
上面的代码已经测试过可以直接使用: 语音转文字
按键模块
源码:linux/drivers/input at r329-wip · sipeed/linux (github.com)
检查按键驱动是否正常
sudo apt-get install evtest
# 按下按键查看是否有检测到按键事件
sudo evtest
安装依赖
sudo apt-get install -y Python3 Python3-pip
pip3 install evdev
通过程序检测按键是否按下
import evdev
log_dbg = print
e = evdev.InputDevice('/dev/input/event0')
for event in e.device.read_loop():
if event.type == evdev.ecodes.EV_KEY:
key_event = evdev.categorize(event)
if key_event.keystate == key_event.key_down:
log_dbg(f"按键按下: {key_event.keycode}")
elif key_event.keystate == key_event.key_up:
log_dbg(f"按键松开: {key_event.keycode}")
显示模块
源码:
- linux/drivers/gpu at r329-wip · sipeed/linux (github.com)
- aimi_board/display_driver at main · observerkei/aimi_board (github.com)
显示部分通过framebuffer
方式驱动240x240分辨率的SPI屏幕。
framebuffer可以理解为linux提供的一种内存到屏幕像素的显示映射, 在屏幕设备加载完成后,会在linux的文件系统下创建一个
/dev/fb0
的设备文件,通过在程序里面使用linux提供的系统调用访问这个设备文件,可以读取到屏幕的分辨率、显示方式、以及能直接通过内存映射的方式修改屏幕上显示的像素点。
framebuffer有多种RGB显示类型,RGB表示的是一个LED像素可以显示三种颜色,而RGB显示类型中有一种RGB565的显示方式,这种显示方式表示的是 RGB的 Read/Green/Blue 三种颜色分别分配的比特位, 加起来刚好是 位,而8位是1个字节,在内存中则表示RGB565对应显示一个RGB灯珠的时候,占用 2个字节(也就是16位),而人眼对于绿色更加敏感,所以Green分配的比特相对其他颜色多一位。
通过framebuffer方式访问屏幕设备文件 /dev/fb0
的时候,可以将屏幕对应像素通过 mmap
方式映射到内存中,这块内存就相当于显存,操作这块内存可以直接修改屏幕上显示的像素点。
而mmap
的这个显存中内存和屏幕像素的对应关系类似如下:
比如有一块分辨率为8x8的RGB565屏幕,正常看的话是一块正方形屏幕。
○: 表示像素点
+ 第一个像素位
|
v
○○○○○○○○ <--+ 第8个像素位置
○○○○○○○○ <--+ 第16个像素位
○○○○○○○○
○○○○○○○○
○○○○○○○○
○○○○○○○○
○○○○○○○○
○○○○○○○○ <--+ 最后一个像素位
RGB565一个像素占用两个字节,映射到内存中的话如下:
○: 表示一个字节
+ 起始地址,第一个和第二个字节一起表示,第一个像素.
| 每个像素占用2字节,像素从左到右,从上到下依次在内存中顺序排列.
| 修改内存对应值就是修改像素.
|
v
○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○
将字体显示到屏幕上
字体文件
有16x16 像素 GB2312 中文字符和 8X16像素 的ASCII 英文字符两种
aimi_board/display_driver/font at main · observerkei/aimi_board · GitHub
字体处理方式
代码如下: aimi_board/display_driver/font_bitmap.cpp at main · observerkei/aimi_board · GitHub
通过网络上的字体制作软件,可以把字体文件(比如ttf后缀的文件)经过点阵字处理后,按照比特位依次写入到内存中。
假设把字符0
处理成4x4点阵字的话,显示效果类似如下:
○: 空白点
●: 亮点
○●●○
●○○●
●○○●
○●●○
如果忽略空白点, 0的显示效果如下
●●
● ●
● ●
●●
比如占用4x4像素,每个像素用1比特表示的话,单个字体可以以如下方式进行映射:
○: 表示字体像素位
+ 第一个字体像素位
|
v
○○○○ <--+ 第4个字体像素位置
○○○○
○○○○
○○○○ <--+ 最后一个字体像素位
4x4个像素占用一个字体, 映射到文件中后,编排方式如下:
○: 表示一个字节
+ 起始地址,第一个字节包含第一个字体像素位和第二个字体像素位.
| 因为使用4x4字体像素,每个字体像素占用1比特,而1字节有8比特, 因此使用2个字节可以表示1个字体.
|
v
○○○○○○○○○○○○○○○○
出于字体保存方便, GB2312的中文字符从 0xa0
开始逐字写入字体文件(字体文件),也就是第一个存放的字对应的编码是 0xa0开始, 而每个16x16中文字体点阵信息是对应占用32个字节,第二个字则是从0xc0
开始,后面的字体依次按照编码计算即可。
而ASCII字体文件(字体文件)的英文字符则是和编码一致,从0x0
开始,也就是第一个ASCII字符对应的编码就是0, 每个8x16 ASCII字体占用16字节,第二个ASCII字符则从 0x10
开始,后面的字体依次按照编码计算即可。
字体编码的转换
因为中文字体是GB2312的编码,当输入不是GB2312编码的时候,需要先转换为GB2312编码然后才能正常进行显示。
考虑到iconv库有标准的编码转换,因此示例代码如下:
#include <iconv.h>
#include <stdio.h>
#include <stdlib.h>
/*
* 任意编码到 GB2312 的转换.
* @param from_code: 来源编码
* @param src_size: 来源字符串长度
* @param str: 来源字符串
* @param dest_size: 目标缓冲区空间大小
* @param dest: 转换结果保存缓冲区
* @return:
* 失败返回 < 0
* 成功返回 使用的字符长度.
*
*/
int str_to_gb2312(const char* from_code, size_t src_size, const char* src, const size_t dest_size, char* dest)
{
// 创建 iconv 转换句柄 GB2312 <- from_code
iconv_t cd = iconv_open("GB2312", from_code);
if (cd == (iconv_t)-1) {
fprintf(stderr, "fail to iconv open gb2312");
return -1;
}
char* p_src = (char*)src;
char* p_dest = dest;
size_t leat_size = dest_size;
if (iconv(cd, &p_src, &src_size, &p_dest, &leat_size) == (size_t)-1) {
fprintf(stderr, "fail to iconv gb2312");
iconv_close(cd);
return -1;
}
// 关闭 iconv 转换句柄
iconv_close(cd);
return dest_size - leat_size;
}
抽象出视图
代码: aimi_board/display_driver/display.cpp at main · observerkei/aimi_board (github.com)
出于性能方面考虑,逐步打点的话,明显会进行多次系统调用,进而影响调用性能,因此抽象出一个视图,然后在视图写完成后,再把显示的内容拷贝到framebuffer中,这样的话可以减少调用次数,进而提升性能,并且因为抽象出了多个视图,因此不同位置的视窗互相堆叠也是可以实现的。
视图view相对于显示屏display的显示关系如下:
* display
* +----------------------+
* | view_start +-----+ |
* | +-------+ |view2| |
* | | view1 | +-----+ |
* | | | |
* | +-------+ |
* +----------------------+
结构对应如下:
typedef struct view_t {
const size_t start_x; // 视图开始x位置
const size_t start_y; // 视图开始y位置
const size_t width; // 视图宽
const size_t height; // 视图高
size_t now_x; // 当前绘制x位置
size_t now_y; // 当前绘制y位置
framebuffer_color_t font_color; // 绘制颜色
} view_t;
实现显示功能对应的接口设计
/*
* @ Display
* @ 原点点定义: 0点为屏幕正方的左上角
* @ 方向定义
*
* |<--width-->
* —— 0+----------> x++
* ^ |
* | |
* height |
* | |
* v v
* y++
*
* */
struct display_t;
/**
* 释放显示设备所占用的内存空间。
*
* @param d 指向显示设备的指针。
*/
void display_exit(display_t *d);
/**
* 初始化显示设备。
*
* @param fb_dev 帧缓冲设备文件的路径。
* @param font_path 字体文件的路径。
* @return 指向初始化后的显示设备的指针。
*/
display_t *display_init(const char *fb_dev, const char *font_path);
/**
* 设置调试模式。
*
* @param enable 是否启用调试模式,1 为启用,0 为禁用。
*/
void display_set_debug(char enable);
/**
* 获取显示设备的宽度。
*
* @param d 指向显示设备的指针。
* @return 显示设备的宽度。
*/
size_t display_get_width(display_t *d);
/**
* 获取显示设备的高度。
*
* @param d 指向显示设备的指针。
* @return 显示设备的高度。
*/
size_t display_get_height(display_t *d);
/**
* 刷新显示设备的内容。
*
* @param d 指向显示设备的指针。
*/
void display_fflush(display_t *d);
/**
* 清空视图的内容。
*
* @param d 指向显示设备的指针。
* @param v 指向视图的指针。
*/
void display_view_clear(display_t* d, view_t* v);
/**
* 在缓存上设置指定位置的颜色。
*
* @param d 指向显示设备的指针。
* @param x 指定位置的横坐标。
* @param y 指定位置的纵坐标。
* @param color 要设置的颜色。
*/
void display_set_cache_color(display_t* d, size_t x, size_t y, framebuffer_color_t color);
/**
* 在视图上打印字符串。(支持中文)
*
* @param d 指向显示设备的指针。
* @param v 指向视图的指针。
* @param from_code 字符串的编码格式。
* @param str 要打印的字符串。
* @param str_len 要打印的字符串长度。
* @return 打印成功返回 0,失败返回 -1。
*/
int display_view_print(display_t* d, view_t *v, const char *from_code, const char* str, size_t str_len);
CPP接口的Python化封装
有的项目使用Python开发的话,依赖于公共库,可以降低维护成本, 这里使用了 ctypes 来进行Python接口封装,在Python中定义了C的结构体封装、Python调用C接口的时候,进行结构体传参的处理,在Python中如何修改从C中读取到数据,函数如何调用等。
又因为CPP可以导出C接口,因此同样也支持CPP接口封装。
这个是官方的使用文档: https://docs.python.org/3/library/ctypes.html
我这里提供具体的使用案例。
具体代码如下: aimi_board/display_driver/init.py at main · observerkei/aimi_board (github.com)
Python中定义C的基础类型
比如定义 uint16
结构时,操作如下:
from ctypes import c_uint16
def create(color: int):
return c_uint16(color)
Python中定义C的结构体
from ctypes import Structure, c_size_t, c_uint16
#
# @ Display
# @ 原点点定义: 0点为屏幕正方的左上角
# @ 方向定义
#
# |<--width-->
# —— 0+----------> x++
# ^ |
# | |
# height |
# | |
# v v
# y++
#
#
# Structure link: https://docs.python.org/3/library/ctypes.html
# typedef struct view_t {
# const size_t start_x; // 视图开始x位置
# const size_t start_y; // 视图开始y位置
# const size_t width; // 视图宽
# const size_t height; // 视图高
# size_t now_x; // 当前绘制x位置
# size_t now_y; // 当前绘制y位置
# framebuffer_color_t font_color; // 绘制颜色
# } view_t;
class View(Structure):
start_x: int
start_y: int
width: int
height: int
now_x: int
now_y: int
font_color: int
_fields_ = [
("start_x", c_size_t),
("start_y", c_size_t),
("width", c_size_t),
("height", c_size_t),
("now_x", c_size_t),
("now_y", c_size_t),
("font_color", c_uint16),
]
Python中封装C接口
具体有以下步骤即可完成配置
- 加载编译好的C的SO文件(CPP可以导出C接口)
- 配置
argtypes
- 配置
restype
这里以一些相对复杂的接口来示例:
/**
* 释放显示设备所占用的内存空间。
*
* @param d 指向显示设备的指针。
*/
void display_exit(display_t *d);
/**
* 初始化显示设备。
*
* @param fb_dev 帧缓冲设备文件的路径。
* @param font_path 字体文件的路径。
* @return 指向初始化后的显示设备的指针。
*/
display_t *display_init(const char *fb_dev, const char *font_path);
/**
* 在视图上打印字符串。(支持中文)
*
* @param d 指向显示设备的指针。
* @param v 指向视图的指针。
* @param from_code 字符串的编码格式。
* @param str 要打印的字符串。
* @param str_len 要打印的字符串长度。
* @return 打印成功返回 0,失败返回 -1。
*/
int display_view_print(display_t* d, view_t *v, const char *from_code, const char* str, size_t str_len);
封装成Python接口的话,操作如下:
POINTER(c_char)
表示 c_char
类型的指针
argtypes
表示配置调用参数类型
restype
表示配置返回值类型
from ctypes import Structure, cdll, c_int, c_size_t, c_uint16, c_void_p, c_char, POINTER
from typing import Any
class Color:
Black: int = c_uint16(0x0000)
While: int = c_uint16(0xFFFF)
def create(color: int):
return c_uint16(color)
class View(Structure):
start_x: int
start_y: int
width: int
height: int
now_x: int
now_y: int
font_color: int
_fields_ = [
("start_x", c_size_t),
("start_y", c_size_t),
("width", c_size_t),
("height", c_size_t),
("now_x", c_size_t),
("now_y", c_size_t),
("font_color", c_uint16),
]
class Display:
display_so: Any
display_driver: Any = None
def __init__(self, driver_so_path: str, framebuffer_dev: str, font_path: str):
# 加载编译好的 .so , driver_so_path 是so路径
self.display_so = cdll.LoadLibrary(driver_so_path)
# 传入默认的参数初始化
self.__hook_setup()
# 进行初始化, 字符串要使用 .encode() 来进行传参
# framebuffer_dev 和 font_path 是C接口需要的参数
self.display_driver = self.display_so.display_init(framebuffer_dev.encode(), font_path.encode())
def __hook_setup(self):
# void display_exit(display_t *d);
self.display_so.display_exit.argtypes = [POINTER(c_void_p)]
# display_t *display_init(const char *fb_dev, const char *font_path);
self.display_so.display_init.argtypes = [POINTER(c_char), POINTER(c_char)]
self.display_so.display_init.restype = POINTER(c_void_p)
# int display_view_print(display_t* d, view_t *v, const char *from_code, const char* str, size_t str_len);
self.display_so.display_view_print.argtypes = [
POINTER(c_void_p),
POINTER(View),
POINTER(c_char),
POINTER(c_char),
c_size_t,
]
self.display_so.display_view_print.restype = c_int
def display_view_print(self, v: View, from_code: str, content: str):
"""
在指定视图上打印字符串。
Args:
v (View): 要打印字符串的视图对象。
from_code (str): 字符串的编码格式。
content (str): 要打印的字符串内容。
"""
return self.display_so.display_view_print(
self.display_driver,
v,
from_code.encode(),
content.encode(),
len(content.encode()),
)
def __del__(self):
# 别忘了释放资源
if self.display_driver:
self.display_so.display_exit(self.display_driver)
这样外部就能通过这样方式进行C函数调用:
display = Display(...)
v = View(...)
display.display_view_print(v, "UTF-8", "Hello World!")