Wio Terminal 语音转文字实战:基于 Azure Speech Service 的智能定时器实现
【免费下载链接】IoT-For-Beginners12 Weeks, 24 Lessons, IoT for All!项目地址: https://gitcode.com/GitHub_Trending/io/IoT-For-Beginners
本篇技术指南聚焦于 IoT-For-Beginners 课程「消费者项目(6-consumer)」第一课《语音识别》中Wio Terminal 语音转文字(Speech to Text)的完整实现。你将掌握如何让 Wio Terminal 把通过板载麦克风录制到 Flash 内存的 4 秒 WAV 音频,通过 Azure 认知服务 Speech Service 的 REST API 转换为文本,并学会处理访问令牌过期、HTTPS 证书校验、内存受限设备上的音频流式上传等关键工程问题。读完本文,你可以在自己的smart-timer工程中复现一整套可运行的端到端语音识别链路,为后续课程中的文本转语音(TTS)与多语言支持打下基础。
语音转文本的整体架构:从录音到 REST API
在上一部分课程(wio-terminal-audio.md)中,录音数据已通过 DMA + ADC 采样写入 Wio Terminal 的外部 Flash 内存。本课的任务是把这段录音发送给语音服务并取回识别文本,其核心链路如下:
- 获取访问令牌(Access Token):先向令牌颁发服务发起 POST 请求,用
SPEECH_API_KEY换得一个有效期10 分钟的访问令牌; - 流式读取录音:由于 WAV 音频整体无法放进 Wio Terminal 仅有 192KB 的 RAM,必须实现一个 Arduino
Stream子类(FlashStream),按块从 Flash 读出并交给 HTTP 客户端上传; - 调用识别 REST API:以
Authorization: Bearer <token>携带令牌、以audio/wav; codecs=audio/pcm; samplerate=16000声明音频格式,POST 上传录音,解析返回 JSON 中的DisplayText字段得到文本。
整套实现对应的完整工程位于仓库 code-speech-to-text/wio-terminal 目录下(PlatformIO 工程smart-timer)。本文所有代码片段均与该工程 src 目录下的实际源码保持一致。
在开始编写代码前,请先按照课程 README.md 中的说明创建语音服务资源(Resource Group 命名为smart-timer,通过az cognitiveservices account create --name smart-timer --kind SpeechServices --sku F0创建免费层资源),并用az cognitiveservices account keys list拿到 API Key 与资源所在位置(Location),它们将用于下面的config.h配置。
任务一:获取访问令牌
语音服务的 REST API 采用令牌鉴权机制:先用订阅密钥换取短期访问令牌,再用该令牌访问识别接口。令牌默认 10 分钟过期,因此代码需要具备在令牌失效后重新获取并重试的能力。
添加 WiFi 与 JSON 处理依赖
打开 PlatformIO 工程smart-timer的 platformio.ini,在lib_deps中加入以下依赖(工程完整配置中还包含上一课录音所需的Seeed Arduino FS与Seeed Arduino SFUD):
[env:seeed_wio_terminal] platform = atmelsam board = seeed_wio_terminal framework = arduino lib_deps = seeed-studio/Seeed Arduino FS @ 2.1.1 seeed-studio/Seeed Arduino SFUD @ 2.0.2 seeed-studio/Seeed Arduino rpcWiFi @ 1.0.5 seeed-studio/Seeed Arduino rpcUnified @ 2.1.3 seeed-studio/Seeed_Arduino_mbedtls @ 3.0.1 seeed-studio/Seeed Arduino RTC @ 2.0.0 bblanchon/ArduinoJson @ 6.17.3各依赖的作用:
Seeed Arduino rpcWiFi @ 1.0.5:Wio Terminal 基于 RPC 机制的 WiFi 客户端库,提供WiFi.begin()、WiFi.status()等标准接口;Seeed Arduino rpcUnified @ 2.1.3:rpcWiFi 的底层依赖;Seeed_Arduino_mbedtls @ 3.0.1:mbedTLS 实现,为WiFiClientSecure提供 HTTPS/TLS 能力;Seeed Arduino RTC @ 2.0.0:实时时钟库(rpcUnified 的传递依赖);bblanchon/ArduinoJson @ 6.17.3:用于解析语音服务返回的 JSON 响应。
配置 WiFi、密钥与语言
在 config.h 中加入以下常量(工程该文件头部还定义了录音相关的宏,一并列出):
#define RATE 16000 // 采样率 16KHz #define SAMPLE_LENGTH_SECONDS 4 // 录制时长 4 秒 #define SAMPLES RATE * SAMPLE_LENGTH_SECONDS #define BUFFER_SIZE (SAMPLES * 2) + 44 // PCM16 单声道数据 + 44 字节 WAV 头 #define ADC_BUF_LEN 1600 const char *SSID = "<SSID>"; const char *PASSWORD = "<PASSWORD>"; const char *SPEECH_API_KEY = "<API_KEY>"; const char *SPEECH_LOCATION = "<LOCATION>"; const char *LANGUAGE = "<LANGUAGE>"; const char *TOKEN_URL = "https://%s.api.cognitive.microsoft.com/sts/v1.0/issuetoken";替换说明:
<SSID>/<PASSWORD>:你的 WiFi 网络凭据;<API_KEY>:创建语音服务资源时获得的密钥;<LOCATION>:创建资源时指定的区域(如eastasia、westeurope),它会拼接进令牌颁发 URL 的主机名;<LANGUAGE>:说话语言的区域设置名称(locale),例如en-GB表示英语,zh-HK表示粤语。完整支持列表参见课程文档所引用的 Microsoft Docs 语言支持页面;TOKEN_URL常量是不含区域的令牌颁发地址模板,%s占位符会在运行时被SPEECH_LOCATION替换,最终形如https://eastasia.api.cognitive.microsoft.com/sts/v1.0/issuetoken。
配置 HTTPS 证书
与之前连接 Custom Vision 一样,令牌颁发服务走 HTTPS,Wio Terminal 上没有系统根证书库,必须显式设置 CA 证书。在config.h末尾追加TOKEN_CERTIFICATE常量:
const char *TOKEN_CERTIFICATE = "-----BEGIN CERTIFICATE-----\r\n" "MIIF8zCCBNugAwIBAgIQAueRcfuAIek/4tmDg0xQwDANBgkqhkiG9w0BAQwFADBh\r\n" "...(完整 PEM 证书内容,见仓库 config.h)...\r\n" "-----END CERTIFICATE-----\r\n";该证书与连接 Custom Vision 时使用的证书相同,是微软 Azure TLS 签发 CA 的公钥证书。完整 PEM 内容可直接从 config.h 复制,切勿改动中间任何一行(证书以\r\n拼接以适配 Arduino 字符串字面量)。
连接 WiFi
在 main.cpp 顶部加入 WiFi 头文件与配置文件引用:
#include <Arduino.h> #include <rpcWiFi.h> #include <sfud.h> #include <SPI.h> #include "config.h" #include "mic.h" #include "speech_to_text.h"在setup函数上方定义connectWiFi,用阻塞式轮询等待连接成功:
void connectWiFi() { while (WiFi.status() != WL_CONNECTED) { Serial.println("Connecting to WiFi.."); WiFi.begin(SSID, PASSWORD); delay(500); } Serial.println("Connected!"); }并在setup中建立串口连接后调用它。从仓库中的 main.cpp 可以看到完整的setup顺序:串口初始化 →connectWiFi()→sfud_init()初始化 Flash →sfud_qspi_fast_read_enable开启 QSPI 快速读 →pinMode(WIO_KEY_C, INPUT_PULLUP)配置按键 C →mic.init()→speechToText.init()→ 打印Ready.。
搭建 SpeechToText 类骨架
在src目录新建speech_to_text.h,加入以下代码:
#pragma once #include <Arduino.h> #include <ArduinoJson.h> #include <HTTPClient.h> #include <WiFiClientSecure.h> #include "config.h" #include "mic.h" class SpeechToText { public: private: }; SpeechToText speechToText;该文件引入了 HTTP 连接、配置与mic.h所需的头文件,声明SpeechToText类,并定义全局实例speechToText供main.cpp使用。在类的private区域添加两个字段:
WiFiClientSecure _token_client; String _access_token;_token_client是基于 HTTPS 的 WiFi 客户端,负责获取访问令牌;获取到的令牌存入_access_token。
实现 getAccessToken
在private区域实现令牌获取方法(与仓库 speech_to_text.h 一致):
String getAccessToken() { char url[128]; sprintf(url, TOKEN_URL, SPEECH_LOCATION); HTTPClient httpClient; httpClient.begin(_token_client, url); httpClient.addHeader("Ocp-Apim-Subscription-Key", SPEECH_API_KEY); int httpResultCode = httpClient.POST("{}"); if (httpResultCode != 200) { Serial.println("Error getting access token, trying again..."); delay(10000); return getAccessToken(); } Serial.println("Got access token."); String result = httpClient.getString(); httpClient.end(); return result; }这段代码的要点:
- 用
sprintf把SPEECH_LOCATION填入TOKEN_URL模板,拼出完整令牌地址; httpClient.begin(_token_client, url)让 HTTP 客户端走带证书的 HTTPS 通道;- 通过
Ocp-Apim-Subscription-Key请求头携带订阅密钥,向令牌端点 POST{}; - 非 200 响应时打印错误、等待 10 秒并递归重试;成功则返回令牌字符串。
在public区域添加访问器AccessToken(),供后续课程实现文本转语音(TTS)时复用:
String AccessToken() { return _access_token; }再添加init方法,为令牌客户端设置 CA 证书并拉取首个令牌:
void init() { _token_client.setCACert(TOKEN_CERTIFICATE); _access_token = getAccessToken(); }在main.cpp的 include 区加入#include "speech_to_text.h",并在setup末尾、mic.init()之后、打印Ready之前调用speechToText.init();。
任务二:从 Flash 内存流式读取录音
为什么需要 FlashStream
上一课将音频写入了外部 Flash 内存,本任务要把这段音频发给 Speech REST API。Wio Terminal 只有 192KB 内存,而 4 秒 16KHz 16-bit 单声道 WAV 数据约为16000 × 4 × 2 + 44 = 128044字节(约 125KB),无法整段载入内存缓冲区。解决方案是利用HTTPClient对 ArduinoStream的支持:Stream每次read()只返回一小块数据,HTTP 客户端在发送请求体时可以边读边传。FlashStream就是这样一个「从 Flash 读、往 HTTP 传」的只读流。
在src目录新建flash_stream.h,先搭建继承自 ArduinoStream的骨架:
#pragma once #include <Arduino.h> #include <HTTPClient.h> #include <sfud.h> #include "config.h" class FlashStream : public Stream { public: virtual size_t write(uint8_t val) { } virtual int available() { } virtual int read() { } virtual int peek() { } private: };Stream是抽象类,派生类必须实现write、available、read、peek四个纯虚函数后才能实例化。
FlashStream 类的字段与缓冲区
在private区域添加状态字段:
size_t _pos; size_t _flash_address; const sfud_flash *_flash; byte _buffer[HTTP_TCP_BUFFER_SIZE];_pos:当前在内存缓冲区中的读取位置;_flash_address:当前要从 Flash 读取的地址;_flash:SFUD(串行 Flash 通用驱动库)设备指针;_buffer:临时缓冲区,大小取HTTP_TCP_BUFFER_SIZE——即HTTPClient单次向 REST API 发送的最大分块尺寸(该常量由 HTTPClient 库提供,通常对应 TCP 单包大小)。
配套的populateBuffer()私有方法从 Flash 读出一整块数据:
void populateBuffer() { sfud_read(_flash, _flash_address, HTTP_TCP_BUFFER_SIZE, _buffer); _flash_address += HTTP_TCP_BUFFER_SIZE; _pos = 0; }它调用 SFUD 的sfud_read按当前地址读入缓冲区,然后推进地址、复位位置,使下一次调用读取下一块内存。> 注意:Flash 的擦除必须按 grain(粒度)进行,但读取不受此限制,因此这里可以按HTTP_TCP_BUFFER_SIZE任意分块。
构造函数与 Stream 抽象方法实现
构造函数将所有字段初始化为从 Flash 起始位置读取,并预载第一块数据:
FlashStream() { _pos = 0; _flash_address = 0; _flash = sfud_get_device_table() + 0; populateBuffer(); }sfud_get_device_table() + 0取得设备表中的第一个 Flash 设备(Wio Terminal 板载 W25Q32)。随后依次实现四个抽象方法:
write—— 本流只读,直接返回 0:
virtual size_t write(uint8_t val) { return 0; }peek—— 返回当前位置的数据但不推进流;只要没有执行read,多次peek结果一致:
virtual int peek() { return _buffer[_pos]; }available—— 返回还可读取的字节数,流读完返回 -1:
virtual int available() { int remaining = BUFFER_SIZE - ((_flash_address - HTTP_TCP_BUFFER_SIZE) + _pos); int bytes_available = min(HTTP_TCP_BUFFER_SIZE, remaining); if (bytes_available == 0) { bytes_available = -1; } return bytes_available; }逻辑说明:HTTPClient在发送请求体时先调用available()询问数据量,再按该量请求数据。为避免每次分块超过 HTTP 客户端的分块尺寸,这里把可用量钳制在HTTP_TCP_BUFFER_SIZE以内;BUFFER_SIZE是录音总量(含 44 字节 WAV 头),减去已消费字节即剩余量;剩余为 0 时返回 -1 表示流结束。
read—— 返回缓冲区下一个字节并递增位置;位置到达缓冲区末尾时重新填充下一块:
virtual int read() { int retVal = _buffer[_pos++]; if (_pos == HTTP_TCP_BUFFER_SIZE) { populateBuffer(); } return retVal; }最后回到speech_to_text.h,加入该头文件的引用:
#include "flash_stream.h"任务三:调用 REST API 将语音转换为文本
配置语音识别证书与 URL
语音识别 REST API 与令牌颁发服务使用不同的证书,需要在 config.h 中追加SPEECH_CERTIFICATE常量:
const char *SPEECH_CERTIFICATE = "-----BEGIN CERTIFICATE-----\r\n" "MIIF8zCCBNugAwIBAgIQCq+mxcpjxFFB6jvh98dTFzANBgkqhkiG9w0BAQwFADBh\r\n" "...(完整 PEM 证书内容,见仓库 config.h)...\r\n" "-----END CERTIFICATE-----\r\n";同时添加不含区域与语言的识别 URL 模板:
const char *SPEECH_URL = "https://%s.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=%s";两个占位符将分别被SPEECH_LOCATION与LANGUAGE替换,例如https://eastasia.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-GB。
组装 HTTP 请求
在SpeechToText类的private区域添加使用语音证书的 WiFi 客户端字段:
WiFiClientSecure _speech_client;并在init中为其设置 CA 证书:
_speech_client.setCACert(SPEECH_CERTIFICATE);接着在public区域定义convertSpeechToText()方法(与仓库 speech_to_text.h 完整实现一致)。第一步,用区域与语言拼出完整 URL,创建 HTTP 客户端:
String convertSpeechToText() { char url[128]; sprintf(url, SPEECH_URL, SPEECH_LOCATION, LANGUAGE); HTTPClient httpClient; httpClient.begin(_speech_client, url);第二步,设置三个关键请求头:
httpClient.addHeader("Authorization", String("Bearer ") + _access_token); httpClient.addHeader("Content-Type", String("audio/wav; codecs=audio/pcm; samplerate=") + String(RATE)); httpClient.addHeader("Accept", "application/json;text/xml");Authorization:以 Bearer 方式携带访问令牌;Content-Type:声明 WAV/PCM 音频格式与采样率(RATE即 16000),这是服务正确解码音频的前提;Accept:声明期望的响应格式为 JSON/XML。
第三步,创建FlashStream实例并作为请求体流式上传:
Serial.println("Sending speech..."); FlashStream stream; int httpResponseCode = httpClient.sendRequest("POST", &stream, BUFFER_SIZE); Serial.println("Speech sent!");sendRequest("POST", &stream, BUFFER_SIZE)会把整个 WAV 数据(含 44 字节 RIFF/WAVE 头)分块从 Flash 流式读出并发送,无需在内存中保存整段音频。44 字节 WAV 头由上一课 mic.h 中的initBufferHeader()写入 Flash,字段包括RIFF/WAVE标识、PCM 格式(format_tag = 1)、单声道、16KHz 采样率、16-bit 采样位数,因此上传的字节流本身就是合法的 WAV 文件。
处理响应码
上传完成后,根据 HTTP 响应码分三种情况处理:
String text = ""; if (httpResponseCode == 200) { String result = httpClient.getString(); Serial.println(result); DynamicJsonDocument doc(1024); deserializeJson(doc, result.c_str()); JsonObject obj = doc.as<JsonObject>(); text = obj["DisplayText"].as<String>(); } else if (httpResponseCode == 401) { Serial.println("Access token expired, trying again with a new token"); _access_token = getAccessToken(); return convertSpeechToText(); } else { Serial.print("Failed to convert text to speech - error "); Serial.println(httpResponseCode); } httpClient.end(); return text; }- 200 成功:读取响应体,用
ArduinoJson反序列化,取出DisplayText字段——即语音对应的文本内容(如"Set a 2 minute and 27 second timer."); - 401 未授权:说明访问令牌已过期(令牌有效期仅 10 分钟),重新调用
getAccessToken()换取新令牌后递归重试本次识别; - 其他错误:向串口输出错误码,
text保持为空字符串。
集成到 main.cpp
在 main.cpp 的processAudio函数中调用识别方法并打印结果:
void processAudio() { String text = speechToText.convertSpeechToText(); Serial.println(text); }processAudio由loop驱动:当按键 C 被按下(digitalRead(WIO_KEY_C) == LOW)且麦克风空闲时开始录音;录音完成后(mic.isRecordingReady()为真)调用processAudio,然后mic.reset()复位,等待下一次触发。
端到端运行与验证
构建代码并上传到 Wio Terminal,通过 PlatformIO 串口监视器(波特率 9600)测试。看到Ready后,按下C 键(左侧、靠近电源开关的那个按键)并说话,会录制 4 秒音频并转换为文本。预期输出如下:
--- Available filters and text transformations: colorize, debug, default, direct, hexlify, log2file, nocontrol, printable, send_on_enter, time --- More details at http://bit.ly/pio-monitor-filters --- Miniterm on /dev/cu.usbmodem1101 9600,8,N,1 --- --- Quit: Ctrl+C | Menu: Ctrl+T | Help: Ctrl+T followed by Ctrl+H --- Connecting to WiFi.. Connected! Got access token. Ready. Starting recording... Finished recording Sending speech... Speech sent! {"RecognitionStatus":"Success","DisplayText":"Set a 2 minute and 27 second timer.","Offset":4700000,"Duration":35300000} Set a 2 minute and 27 second timer.日志中的RecognitionStatus: Success与DisplayText字段直接来自语音服务返回的 JSON,末尾单独一行是processAudio打印出的纯文本结果。若出现Access token expired, trying again with a new token,说明令牌在测试期间过期,程序会自动换新并重试。
工程中的完整参考实现
仓库已提供本课最终代码,可直接对照或复用:
- code-speech-to-text/wio-terminal/smart-timer/src/config.h:全部常量、两个 PEM 证书与录音参数宏;
- code-speech-to-text/wio-terminal/smart-timer/src/speech_to_text.h:
SpeechToText类的完整实现(令牌获取 + 语音识别 + 401 重试); - code-speech-to-text/wio-terminal/smart-timer/src/flash_stream.h:
FlashStream只读流的完整实现; - code-speech-to-text/wio-terminal/smart-timer/src/main.cpp:
setup/loop/processAudio的集成逻辑; - code-speech-to-text/wio-terminal/smart-timer/src/mic.h:DMA + ADC 采样、WAV 头写入与 Flash 写入回调;
- code-speech-to-text/wio-terminal/smart-timer/platformio.ini:完整依赖清单。
小结与延伸
至此,你的 Wio Terminal 已经具备「按键触发 → 录制 4 秒语音 → Flash 流式上传 → Azure Speech Service 识别 → 返回文本」的完整语音转文字能力。本课还有配套的 Raspberry Pi(pi-speech-to-text.md)与虚拟设备(virtual-device-speech-to-text.md)实现,可对照学习不同硬件上的差异。在后续课程中,AccessToken()方法将被复用于文本转语音(让设备「说话」),并进一步扩展到多语言支持;课程作业见 assignment.md。
需要留意的是,本文实现依赖语音服务 REST API 的令牌机制与TOKEN_URL/SPEECH_URL端点格式,均以当前仓库课程编写时使用的 Azure 认知服务接口为准;若服务端接口或区域端点发生变化,请以你实际创建资源的区域与最新文档为准进行调整。
【免费下载链接】IoT-For-Beginners12 Weeks, 24 Lessons, IoT for All!项目地址: https://gitcode.com/GitHub_Trending/io/IoT-For-Beginners
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考