概要

现在想分析构成一个语音助手终端所需要完成所需要的资源和准备。
首先,需要确定要实现的功能。

功能

  • 语音识别:解析用户输入的语音内容。
  • 语音播报:播放合成语音输出。
  • 语义理解:理解语音内容中的语义, 通过语音进行智能对话。
  • 按钮控制:支持通过按钮控制交互。
  • 屏幕显示:将相关信息显示在屏幕上。

方案构型

为了实现所需要的功能,需要根据已有的资料进行分析,选择什么样子的实现方案。
首先为了完成语音识别, 设备需要使用麦克风。
为了进行语音播报,设备需要有扬声器。
为了进行语义理解,这里直接接入大语言模型。
为了进行按钮控制,设备需要有按钮。
为了实现屏幕显示,设备需要有屏幕外设。
而为了将上面的功能构组合成一个整体,考虑到便携性,可以使用嵌入式设备来进行开发。

修改后保存为png

嵌入式最重要的主控部分,根据类型和官方SDK可以运行不同类型的系统,考虑到开发和维护方便以及功能性验证,这里选择了 Linux系统为基础来进行开发,依赖Linux系统的网络和丰富的API和库,可以方便的进行功能验证。
因为市面上有现成的开发板可以用于快速验证,这里选择了SoC为R329MaixII-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}")

显示驱动

源码:

显示部分通过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!")

实机视频

_____