基于 T-HEAD RVB2601 的云语音识别

前言

本项目旨在实现基于 T-HEAD RVB2601 的云语音识别,主要工作在于拓展官方网络库 HTTPClient,将音频数据上传至 HTTP 服务器。

项目源码 实现效果

目标:按下板载按钮,RVB2601 开始录音,上传云端语音识别后返回识别结果至串口。

思路:实现的程序流程与所需要的关键组件如下:

  1. gpio_pin:设置按键中断。按下按钮后设置“开始录音标志位”为 1。
  2. rhino:注册监控任务。监控“开始录音标志位”,若为 1 则:
    1. codec:进行录音。
    2. HTTPClient :上传录音数据,接收并返回识别结果。
    3. 清空标志位。

下文为完整的项目开发流程,主要分为三部分:

  1. 设计思路,HTTPClient 组件测试。
  2. HTTPClient 组件源码解析。
  3. 项目的完整实现,包括:手动实现“multipart/form-data”类型 POST 请求;完成板端与服务器端开发。

设计思路

在官方提供的 RVB2601 示例程序中:ch2601_ft_demo 实现了麦克风录音并由扬声器回放的功能;ch2601_webplayer_demo 实现了从网络上下载音乐并播放的功能。因此我们有理由相信借助于现有的组件就能够实现一个基于 Http 的云语音识别。

然而,ch2601_webplayer_demo 中使用的网络库并不提供完整的 HTTP 功能,因此我们需要寻找支持 RVB2601 的其它网络库。

通过上网搜寻,找到了 平头哥YOC文档,里面列举了众多 API 以及模块组件;以及对应的 YOC github 源码

其中,YOC 提供一个网络组件:HTTPClient,它“为http/https客户端组件,为用户提供一组简洁的调用接口。”并且看其接口列表它好像还实现了 HTTP 的各种请求(年轻了)。

因此我们选择使用 HTTPClient 组件来帮助我们与服务器通信,上传录音文件并接收识别结果。

HTTPClient 组件测试

由于这是一个没有出现在 RVB2601 示例程序中的组件,我们还不清楚能否使用以及如何使用。所幸源码中提供了 HTTPClient 的测试程序:http_examples。因此我们可以先尝试测试该组件。

项目工程建立在 ch2601_webplayer_demo 上。以下为操作流程:

项目配置

首先从 YOC github 源码 下载所需组件,除了 HTTPClient 外还需要 transport 组件。

进入 CDK 工程,右键 Packages 并新建一个普通包;设置包名以及存储路径;接着向刚刚新建的 Package 里添加下载的源码;最后对 Package 进行配置:在 Compiler 选项卡中设置 Include,以及在 Base 选项卡中编辑 Description。

源码修改

我们直接将 http_examples.c 的内容加入到 player_demo.c 中,并向 CLI 控制台注册一个新命令 http_test:

/* player_demo.c */

static void cmd_http_func(char *wbuf, int wbuf_len, int argc, char **argv)
{
    if (argc == 1 && strcmp(argv[0], "http_test") == 0) {
        test_https();
    }
    else
        printf("\thttp_test\n");
}

int cli_reg_cmd_player(void)
{
    ...
    static const struct cli_command http_cmd_info= {
        "http_test",
        "http_test",
        cmd_http_func,
    };
    aos_cli_register_command(&cmd_info);
    ...
    return 0;
}

这样,当控制台接收到 http_test 命令后就会直接调用 http_examples.c 中的测试函数 test_https()。

测试后没有什么问题,因此我们能够在 RVB2601 上调用 HTTPClient 提供的接口,来帮助我们与服务器通信。接下来我们需要理解该组件提供的功能,并实现文件对服务器的上传。

源码解析

首先,根据 HTTPClient 组件的示例程序 http_examples.c,我们得知了一个完整的 HTTP 请求需要调用的接口流程,大致如下。以 POST 请求为例,并直接设置 url 而非 host&path:

http_client_config_t config = {
    .url = "http://httpbin.org/get",
    .event_handler = _http_event_handler,
};
http_client_handle_t client = http_client_init(&config);

const char *post_data = "field1=value1&field2=value2";
http_client_set_method(client, HTTP_METHOD_POST);
http_client_set_post_field(client, post_data, strlen(post_data));
err = http_client_perform(client);
if (err == HTTP_CLI_OK) {
    LOGI(TAG, "HTTP POST Status = %d, content_length = %d \r\n",
            http_client_get_status_code(client),
            http_client_get_content_length(client));
} else {
    LOGE(TAG, "HTTP POST request failed: 0x%x @#@@@@@@", (err));
    e_count ++;
}

整个流程主要涉及到以下几个函数:

http_client_init() 主要是基于 config 对 client 结构的各种变量赋初值,包括在请求中会用的到的各种缓存空间,以及 header 中的基本信息。其中 config 未设置的会赋缺省值。也可以用库提供的各种 set_xxx 函数来对变量赋值。

http_client_set_post_field():对于有请求体的 POST,我们需要调用这个函数将发送数据的指针以及长度赋予 client:

web_err_t http_client_set_post_field(http_client_handle_t client, const char *data, int len)
{
    web_err_t err = WEB_OK;
    client->post_data = (char *)data;   // 待发送数据指针
    client->post_len = len;             // 待发送数据长度
    LOGD(TAG, "set post file length = %d", len);
    // 如果此前没有设置 Content-Type,则设置为 application/x-www-form-urlencoded
    if (client->post_data)
    {
        char *value = NULL;
        if ((err = http_client_get_header(client, "Content-Type", &value)) != WEB_OK)
        {
            return err;
        }
        if (value == NULL)
        {
            err = http_client_set_header(client, "Content-Type", "application/x-www-form-urlencoded");
        }
    }
    else
    {
        client->post_len = 0;
        err = http_client_set_header(client, "Content-Type", NULL);
    }
    return err;
}

阅读源码可见,库默认支持“Content-Type”为“application/x-www-form-urlencoded”的 POST 请求,而我们要上传音频文件是“multipart/form-data”类型的。然而浏览了整个 HTTPClient 组件发现它就支持这一种类型……POST 的具体实现也只支持这一种。我们接着往下看就知道了……

http_client_perform():无论是什么 HTTP 请求,在设置完 client 之后最后都是经由这一个函数来具体实现。该函数将根据当前所处的 HTTP 请求流程,调用对应的函数,而这些函数最终会调用 transport 组件即在传输层上读写,最终实现整个 HTTP 请求。http_client_perform() 依次主要调用了以下几个函数:

http_client_prepare();
http_client_connect();
http_client_request_send();
http_client_send_post_data();
http_client_fetch_headers();
http_check_response();
http_client_get_data();
http_client_close();

实现的功能与其函数名一致,也是我们熟知的 HTTP 请求流程。其中我们最关心的当然是http_client_send_post_data():

static web_err_t http_client_send_post_data(http_client_handle_t client)
{   
    // 此时请求头应发送完毕
    if (client->state != HTTP_STATE_REQ_COMPLETE_HEADER)
    {
        LOGE(TAG, "Invalid state");
        return WEB_ERR_INVALID_STATE;
    }
    // 没有要发送的 post_data 直接返回(包括其它请求)
    if (!(client->post_data && client->post_len))
    {
        goto success;
    }

    // 发送 post_data
    int wret = http_client_write(client, client->post_data + client->data_written_index, client->data_write_left);
    if (wret < 0)
    {
        return wret;
    }
    // 在 request send 里面 data_write_left 最后被赋值为 post_len
    client->data_write_left -= wret;
    // 在 request send 里面 data_written_index 最后被赋值为 0
    client->data_written_index += wret;

    // 发送完毕
    if (client->data_write_left <= 0)
    {
        goto success;
    }
    else
    {
        return ERR_HTTP_WRITE_DATA;
    }

success:
    // 更新状态
    client->state = HTTP_STATE_REQ_COMPLETE_DATA;
    return WEB_OK;
}

可见,整个 POST 实现只会发送一次 post_data,它指向我们最初在 http_client_set_post_field() 中填入的待发送数据。然而,我们知道要发送“multipart/form-data”类型的数据,需要按照格式用 boundary 对请求体进行封装,并在请求头中提前告知 boundary。当然,HTTPClient 组件是没有实现这些的……因此如果我们要利用 HTTPClient 发送音频数据,就必须手动实现“multipart/form-data”类型请求体的封装与发送。

当我终于大致理解了 HTTPClient 组件后,我在浏览 YOC 源码时发现了一个就叫“http”的组件,它并没有出现在 YOC 文档里,然而它已经实现了“multipart/form-data”类型的 POST……囿于我有限的技术水平,源码缺少注释,感觉学习成本过高,遂放弃。

事实上针对本项目要实现的云语音识别,YOC 还有很多更适合的组件:麦克风服务,云音交互(AUI Cloud)……甚至后者本身就是为语音识别等应用构建的。然而由于源码没有注释,只有接口介绍文档,以及其它板子上的相关例程,感觉“移植”组件难度过大了……最终都没有使用,选择了基于 HTTPClient 手动实现音频数据的发送。

RVB2601 开发

HTTPClient 发送录音数据

首先从最复杂的,手动发送音频数据开始。

借鉴另一个组件 http 的思路,先针对我们的应用场景设置好 boundary 和请求体的头部、尾部:

// 请求体格式
static const char *boundary = "----WebKitFormBoundarypNjgoVtFRlzPquKE";
#define MY_FORMAT_START "------WebKitFormBoundarypNjgoVtFRlzPquKE\r\nContent-Disposition: %s; name=\"%s\"; filename=\"%s\"\r\nContent-Type: %s\r\n\r\n"
#define MY_FORMAT_END "\r\n------WebKitFormBoundarypNjgoVtFRlzPquKE--\r\n"

由于我们的音频数据较大,且一次流程只发送一个文件,因此发送 POST 请求体时可以分三部分发送:头部、音频数据、尾部。所以在进行音频数据传输时,相比于原来的流程,我们要先设置“Content-Type”,接着基于模板制备请求体头部尾部,设置 Content-Length,并将 POST 相关变量统一存放在一个结构体中,方便进行 POST 请求时调用。以下是音频发送函数:

static void post_audio()
{
    http_client_config_t config = {
        .url = "http://upload.hazhuzhu.com/myasr.php",
        .method = HTTP_METHOD_POST,
        .event_handler = _http_event_handler,
    };

    http_client_handle_t client = http_client_init(&config);
    // 设置 Content-Type
    http_client_set_header(client, "Content-Type", "multipart/form-data; boundary=----WebKitFormBoundarypNjgoVtFRlzPquKE");

    const unsigned char *post_content = repeater_data_addr; // POST 请求体的内容为录音数据
    // 请求体格式相关变量
    const char *content_disposition = "form-data";
    const char *name = "file";
    const char *filename = "raw_recording";
    const char *content_type = "application/octet-stream";
    int boundary_len = strlen(boundary);

    // 制备请求体头部
    int post_start_len = strlen(MY_FORMAT_START) - 8 + strlen(content_disposition) + strlen(name) + strlen(filename) + strlen(content_type) + 1;
    char *post_start = (char *)malloc(post_start_len + 1);
    memset(post_start, 0, sizeof(post_start_len));
    snprintf(post_start, post_start_len, MY_FORMAT_START, content_disposition, name, filename, content_type);
    const char *post_end = MY_FORMAT_END;
    // 设置 Content-Length
    http_client_set_post_len(client, strlen(post_start) + PCM_LEN + strlen(post_end));

    // 设置请求体相关变量
    my_post_data_t post_data = {
        .start = post_start,
        .end = post_end,
        .content = post_content,
        .start_len = strlen(post_start),
        .end_len = strlen(post_end),
        .content_len = PCM_LEN,
    };

    // 进行一次 POST 请求
    http_errors_t err = http_client_myperform(client, &post_data);

    if (err == HTTP_CLI_OK)
    {
        LOGI(TAG, "HTTP POST Status = %d, content_length = %d \r\n",
             http_client_get_status_code(client),
             http_client_get_content_length(client));
        char *raw_data_p;
        result_len = http_client_get_response_raw_data(client, &raw_data_p);
        // 输出 response
        if (result_len)
        {
            char *result = (char *)malloc(result_len + 1);
            memcpy(result, raw_data_p, result_len);
            memcpy(result + result_len, "\0", 1);
            printf("%d\r\n", result_len);
            printf("%s\r\n", result);
            result_len = 0;
            free(result);
        }
    }
    else
    {
        LOGE(TAG, "HTTP POST request failed: 0x%x @#@@@@@@", (err));
        e_count++;
    }

    http_client_cleanup(client);
}

其中,http_client_myperform() 是将 http_client_perform() 中的 http_client_send_post_data() 替换为 http_client_mysend_post_data():

/* http_client.c */

#define MY_BODY_SIZE 1000

static web_err_t http_client_mysend_post_data(http_client_handle_t client, my_post_data_t *post_data)
{
    // 此时请求头应发送完毕
    if (client->state != HTTP_STATE_REQ_COMPLETE_HEADER)
    {
        LOGE(TAG, "Invalid state");
        return WEB_ERR_INVALID_STATE;
    }
    // 没有要发送的 post_data 直接返回(包括其它请求)
    if (!(post_data->content && client->post_len))
    {
        goto success;
    }

    // 发送请求体头部
    int wret = http_client_write(client, post_data->start, post_data->start_len);
    if (wret < 0)
    {
        return wret;
    }
    client->data_write_left -= wret;

    // 发送音频数据,可能发送多次
    int content_idx = 0;
    for (int i = 0; i < post_data->content_len / MY_BODY_SIZE; ++i)
    {
        wret = http_client_write(client, post_data->content + content_idx, MY_BODY_SIZE);
        if (wret < 0)
        {
            return wret;
        }
        client->data_write_left -= wret;
        content_idx += MY_BODY_SIZE;
    }
    wret = http_client_write(client, post_data->content + content_idx, post_data->content_len - content_idx);
    if (wret < 0)
    {
        return wret;
    }
    client->data_write_left -= wret;

    // 发送请求体尾部
    wret = http_client_write(client, post_data->end, post_data->end_len);
    if (wret < 0)
    {
        return wret;
    }
    client->data_write_left -= wret;

    // 发送完毕
    if (client->data_write_left <= 0)
    {
        goto success;
    }
    else
    {
        return ERR_HTTP_WRITE_DATA;
    }

success:
    // 更新状态
    client->state = HTTP_STATE_REQ_COMPLETE_DATA;
    return WEB_OK;
}

相比于原来只发送一次数据,现在分三部分发送。并且,由于网络库提供的 http_client_write() 函数没有考虑 TCP 数据包大小限制,我们手动将音频数据每次 MY_BODY_SIZE 大小,多次发送:

http_client_write() 调用流程:

  1. transport_write()
  2. _write()
  3. select()

整个流程都没有考虑数据包大小,若一次发送数据过大会阻塞,因此需要手动分包。

http_client_myperform() 的其它流程都不需要修改,接收到 response 后库函数会自动解析。阅读源码后发现最后会将服务器端发回的原始数据放在 client->response->buffer->raw_data 里。我们写一个函数提取出来:

/* http_client.c */

int http_client_get_response_raw_data(http_client_handle_t client, char **raw_data)
{
    int raw_len = client->response->buffer->raw_len;
    if (raw_len)
    {
        *raw_data = client->response->buffer->raw_data;
        return raw_len;
    }
    return 0;
}

在 post_audio() 的最后我们会调用这个函数,并将识别结果打印出来。

codec 录制音频

录音部分仿照 ch2601_ft_demo 写。由于 RVB2601 存储资源有限,我们只开辟 49152 字节的录音缓存(不能初始化赋值,否则 flash 装不下)。设置采样率 8000Hz,位深 16bit(更清晰方便识别)。这样大概能录制 1.536s,对于我们演示一些简短的口令来说也够用了。

事实上我们后面要调用的语音识别 api 要求音频文件是单声道的,我们后面还需要进行转换。所以这里其实存在一定的存储空间浪费,但我没有在库里找到只录制单声道音频的方法……默认就是双声道的……

/* 录音 */
static void cmd_mic_handler()
{
    csi_error_t ret;
    csi_codec_input_config_t input_config;
    ret = csi_codec_init(&codec, 0);

    if (ret != CSI_OK)
    {
        printf("csi_codec_init error\n");
        return;
    }

    codec_input_ch.ring_buf = &input_ring_buffer;
    csi_codec_input_open(&codec, &codec_input_ch, 0);
    /* input ch config */
    csi_codec_input_attach_callback(&codec_input_ch, codec_input_event_cb_fun, NULL);
    input_config.bit_width = 16;
    input_config.sample_rate = 8000;
    input_config.buffer = input_buf;
    input_config.buffer_size = INPUT_BUF_SIZE;
    input_config.period = 1024;
    input_config.mode = CODEC_INPUT_DIFFERENCE;
    csi_codec_input_config(&codec_input_ch, &input_config);
    csi_codec_input_analog_gain(&codec_input_ch, 0xbf);
    csi_codec_input_link_dma(&codec_input_ch, &dma_ch_input_handle);

    printf("start recoder\n");
    csi_codec_input_start(&codec_input_ch);

    // 麦克风录音写入数据 48x1024=49152
    while (new_data_flag < 48)
    {
        if (cb_input_transfer_flag)
        {
            csi_codec_input_read_async(&codec_input_ch, repeater_data_addr + (new_data_flag * 1024), 1024);
            cb_input_transfer_flag = 0U; // 回调函数将其置 1
            new_data_flag++;
        }
    }

    new_data_flag = 0;

    printf("stop recoder\n");
    csi_codec_input_stop(&codec_input_ch);
    csi_codec_input_link_dma(&codec_input_ch, NULL);
    csi_codec_input_detach_callback(&codec_input_ch);
    csi_codec_uninit(&codec);

    return;
}

按键中断

初始化时设置按键中断,并在回调函数里设置标志位:

static void gpio_pin_callback(csi_gpio_pin_t *pin, void *arg)
{
    start_to_record = 1;    // 标志位置 1 通知 mic 任务开始录音并上传
}

/* Key1 初始化 */
int btn_init()
{
    // key 1
    memset(&g_handle, 0, sizeof(g_handle));
    csi_pin_set_mux(PA11, PIN_FUNC_GPIO);
    csi_gpio_pin_init(&g_handle, PA11);
    csi_gpio_pin_dir(&g_handle, GPIO_DIRECTION_INPUT);
    csi_gpio_pin_mode(&g_handle, GPIO_MODE_PULLUP);
    csi_gpio_pin_debounce(&g_handle, true);
    csi_gpio_pin_attach_callback(&g_handle, gpio_pin_callback, &g_handle);
    csi_gpio_pin_irq_mode(&g_handle, GPIO_IRQ_MODE_FALLING_EDGE);
    csi_gpio_pin_irq_enable(&g_handle, true);

    return 0;
}

监控任务

编写监控任务并注册。我也写了 cli 命令,可以通过串口控制:

// 监控任务
static void mic_task(void *arg)
{
    while (1)
    {
        // 按下按钮后录制并上传
        if (start_to_record == 1)
        {
            cmd_mic_handler();
            post_audio();
            start_to_record = 0;
        }
        aos_msleep(100);
    }
}


static void cmd_http_func(char *wbuf, int wbuf_len, int argc, char **argv)
{
    if (argc == 2 && strcmp(argv[1], "post") == 0)
    {
        post_audio();
    }
    else
    {
        printf("\thttp post\n");
    }
}

static void cmd_mic_func(char *wbuf, int wbuf_len, int argc, char **argv)
{
    if (argc == 2 && strcmp(argv[1], "record") == 0)
    {
        cmd_mic_handler();
    }
    else
    {
        printf("\tmic record\n");
    }
}

int cli_reg_cmd_asr(void)
{
    char url[128];

    // POST 录音
    static const struct cli_command http_cmd_info = {
        "http",
        "http post",
        cmd_http_func,
    };

    // 录音命令
    static const struct cli_command mic_cmd_info = {
        "mic",
        "mic record",
        cmd_mic_func,
    };

    // 注册命令
    aos_cli_register_command(&http_cmd_info);
    aos_cli_register_command(&mic_cmd_info);

    // 新建麦克风任务
    aos_task_new("mic", mic_task, NULL, 10 * 1024);

    return 0;
}

修改链接文件

如果编译中遇到 SRAM overflowed 的问题,可以修改 configs/gcc_flash.ld:

- REGION_ALIAS("REGION_BSS",     SRAM);
+ REGION_ALIAS("REGION_BSS",     DSRAM);

将 BSS 段存到 DSRAM。ch2601_webplayer_demo 工程中的 linker file 和默认的不一样,不知道为什么……

这样,板端就基本开发完毕了。本来打算将识别结果显示在 oled 上的,然而 flash 不够用了……

服务器开发

服务器端就搭建在 hazhuzhu.com 上,nginx+PHP架构,使用腾讯云提供的语音识别 api。假设板端将请求提交到 http://asr.hazhuzhu.com/myasr.php

nginx

server
{
    listen 80;
    server_name asr.hazhuzhu.com;

    client_max_body_size 128m;

    root /home/wwwroot/asr;
    index index.html index.htm index.php;

    location / {
        try_files $uri $uri/ =404;
    }

    location ~ \.php$ {
        include fastcgi.conf;
        fastcgi_pass    unix:/tmp/php-cgi.sock;
        fastcgi_keep_conn on;
    }
}

PHP

后端负责存储音频文件并调用“一句话识别” api(需要使用腾讯云相关 SDK),最后返回识别结果。api 调用部分腾讯云提供了相关文档和代码生成工具,比较方便。

由于 RVB2601 codec 库存储的录音数据是 PCM 编码,2通道。而 api 只接收 wav/mp3 ,1通道的音频文件。而且,由于我们在 http_client_mysend_post_data() 中手动发送音频时没有考虑大小端的问题,所以服务器接收到我们 16bit 音频还是大端存储的。所以在调用 api 前,还需要修正大小端、通道数和文件格式。处于效率,直接调用 ffmepg 来实现……

<?php
// 调用腾讯云 SDK 省略
$uploads_dir = 'ch2601_recordings';

if ($_FILES['file']['error'] == UPLOAD_ERR_OK)
{
    $tmp_name = $_FILES['file']['tmp_name'];
    // $name = $_FILES['file']['name'];
    $date_str=date('YmdHis');
    move_uploaded_file($tmp_name, "$uploads_dir/$date_str".'-b2.pcm');
    // 用 ffmpeg 转码,先转大小端并压缩为 1 通道,再转为 wav 格式
    exec('ffmpeg -f s16be -ar 8000 -ac 2 -i '."$uploads_dir/$date_str".'-b2.pcm'.' -f s16le -ar 8000 -ac 1 '."$uploads_dir/$date_str".'-l1.pcm');
    exec('ffmpeg -f s16le -ar 8000 -ac 1 -i '."$uploads_dir/$date_str".'-l1.pcm '."$uploads_dir/$date_str".'-l1.wav');

    try {
        // api 调用部分,省略……
        $resp = $client->SentenceRecognition($req);

        $result_str=$resp->getResult();
        $result_file=fopen("$uploads_dir/$date_str".'.txt',"a");
        fwrite($result_file,$result_str);
        fclose($result_file);
        echo $result_str;
    }
    catch(TencentCloudSDKException $e) {
        echo $e;
    }
}

至此我们完成了一个完整的基于 RVB2601 的云语音识别应用。

总结

YOC 功能很强大,针对物联网应用场景的各种组件都有,感觉如果能熟练运用可以开发很多项目。

然而感觉文档写的太简单了,源码也不写注释,或者东写一点西写一点。一个接口什么作用,什么参数源码里面不写,还得翻它的 readme 或者文档才知道。但看了也不一定明白要具体怎么使用,例程极少……就连文档和源码都是我自己不知道从哪里搜出来的……

总之开发地很心累,花了整整5天。像 STM32 的 LL 库虽然例程不如 HAL 库的多,但人家至少文档和源码注释是巴巴适适的……哎,痛苦。

文章作者:哈猪猪
文章链接:https://hazhuzhu.com/mcu/rvb2601-speech_recognizer.html
许可协议: CC BY-NC-SA 4.0

评论

  1. 得体
    12月前
    2023-5-22 13:37:16

    感谢博主,按照这个文章,修改了esp32 的post 过程,完成了文件上传,非常感谢

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇