本文围绕 ggwave 仓库中的 arduino-rx 示例 展开,讲解如何利用 Arduino Nano RP2040 Connect 开发板内置的 PDM 麦克风实现 "data-over-sound"(声音传数据)接收,并可选配 SSD1306 OLED 显示屏实时呈现解码结果、通过按键与蜂鸣器将收到的最新数据回传。读完本文,你将掌握该示例的硬件接线、Arduino 草图的核心实现(音频采样、GGWave 实例配置、协议选择、内存预算)、与 Waver 应用联调的方法,以及底层 FSK 调制与 Reed-Solomon 纠错的原理依据。
一、示例概览:为什么选 Arduino Nano RP2040 Connect
ggwave 是一个 "Tiny>const int kPinLED0 = 2; // 板载 RGB LED 的红色通道 const int kPinButton0 = 5; // D5 按键(按下时回传最后收到的数据) const int kPinSpeaker = 10; // D10 蜂鸣器输出
其中 LED0(引脚 2)在收到新数据后点亮,用于无屏场景下的视觉反馈。
接线全貌可参考示例目录中的面包板接线图:
三、从源码看懂采集链路:PDM 麦克风与环形缓冲区
接收端的数据流是:PDM 麦克风 → ISR 中断回调 → 环形缓冲区 → 主循环逐帧送入ggwave.decode()。这是理解整个示例的关键。
音频采集参数
草图顶部定义了采集参数(arduino-rx.ino):
using TSample = int16_t; // 采样位深:16-bit 有符号整数 const size_t kSampleSize_bytes = sizeof(TSample); // 2 字节/采样 const char channels = 1; // 单声道 const int sampleRate = 6000; // 采样率 6 kHz const int samplesPerFrame = 128; // 每帧 128 个采样- 采样率 6000 Hz 与
samplesPerFrame = 128的组合意味着FFT 频率分辨率(hzPerSample)为 6000/128 = 46.875 Hz/bin,这恰好对应项目 README 中描述的全协议通用频率间隔dF = 46.875 Hz(README.md),是 ggwave 协议的"原生分辨率"; - 每帧 128 个采样、采样率 6000 Hz,一帧音频时长为
128/6000 ≈ 21.3 ms,这是解码器一次处理的最小时间片。
环形缓冲区
音频数据在中断中写入、主循环中读取,因此使用无锁环形缓冲区(arduino-rx.ino):
const int qpow = 9; const int qmax = 1 << qpow; // 512 个采样 volatile int qhead = 0; // 读取指针(主循环推进) volatile int qtail = 0; // 写入指针(ISR 推进) volatile int qsize = 0; // 有效采样数 TSample sampleBuffer[qmax];PDM 中断回调(onPDMdata)
onPDMdata()由 PDM 库在中断上下文(ISR)中调用,因此不能在其中使用Serial打印(草图注释明确提示了这一点,见 arduino-rx.ino):
void onPDMdata() { const int bytesAvailable = PDM.available(); const int nSamples = bytesAvailable/kSampleSize_bytes; if (qsize + nSamples > qmax) { // 缓冲区溢出:说明主循环来不及消费,可增大 qmax err += 10; qhead = 0; qtail = 0; qsize = 0; // 丢弃全部数据 } PDM.read(sampleBuffer + qtail, bytesAvailable); qtail += nSamples; qsize += nSamples; if (qtail > qmax) { err += 1; // qmax 不是采样块大小的整数倍时会触发 } if (qtail >= qmax) { qtail -= qmax; // 环形回绕 } }这个回调还承担了"看门狗"职责:err变量累计错误码,主循环发现err > 0时打印ERROR并清零(arduino-rx.ino)。这通常意味着微控制器无法实时处理采集到的数据——如果出现,应优先增大qmax或减小解码负载。
四、GGWave 实例配置详解
初始化发生在setup()中:先取默认参数,再按需覆盖,最后调用prepare()分配内存。
核心参数
ggwave.setLogFile(nullptr); // 关闭内部日志 auto p = GGWave::getDefaultParameters(); // 获取默认参数 #ifdef LONG_RANGE p.payloadLength = 8; // 长距离模式:载荷 8 字节 #else p.payloadLength = 16; // 默认:固定载荷 16 字节 #endif p.sampleRateInp = sampleRate; // 6000,麦克风采集率 p.sampleRateOut = sampleRate; // 6000,扬声器播放率 p.sampleRate = sampleRate; // 6000,工作采样率 p.samplesPerFrame = samplesPerFrame; // 128 p.sampleFormatInp = GGWAVE_SAMPLE_FORMAT_I16; // 输入 16-bit 整数 p.sampleFormatOut = GGWAVE_SAMPLE_FORMAT_U8; // 输出 8-bit 无符号(蜂鸣器只需频率,不关心幅值) p.operatingMode = GGWAVE_OPERATING_MODE_RX | GGWAVE_OPERATING_MODE_TX | GGWAVE_OPERATING_MODE_USE_DSS | GGWAVE_OPERATING_MODE_TX_ONLY_TONES;各字段含义(依据 include/ggwave/ggwave.h 的注释):
| 参数 | 本示例取值 | 说明 |
|---|---|---|
payloadLength | 16(默认)/ 8(LONG_RANGE) | 固定载荷字节数。> 0时启用固定长度解码,不发射声音标记(源码m_nMarkerFrames = parameters.payloadLength > 0 ? 0 : kDefaultMarkerFrames,见 src/ggwave.cpp),解码更快且更省带宽 |
sampleRateInp | 6000 | 采集采样率,与sampleRate不同时内部自动重采样 |
sampleRateOut | 6000 | 播放采样率,同理可自动重采样 |
sampleRate | 6000 | 工作采样率,范围在kSampleRateMin = 1000到kSampleRateMax = 96000之间 |
samplesPerFrame | 128 | FFT 帧大小,决定频谱 bin 数;默认值是 1024 |
sampleFormatInp | I16 | 麦克风数据格式(16-bit 有符号) |
sampleFormatOut | U8 | 输出波形格式 |
operatingMode | RX+TX+USE_DSS+TX_ONLY_TONES | 见下文 |
操作模式位掩码
操作模式来自 ggwave.h 的位定义:
GGWAVE_OPERATING_MODE_RX = 1 << 1, // 允许接收 GGWAVE_OPERATING_MODE_TX = 1 << 2, // 允许发送 GGWAVE_OPERATING_MODE_RX_AND_TX = RX | TX, GGWAVE_OPERATING_MODE_TX_ONLY_TONES = 1 << 3, // 只生成"音调列表"而非完整波形(低内存设备专用) GGWAVE_OPERATING_MODE_USE_DSS = 1 << 4, // 启用内置直序扩频(DSS)算法TX_ONLY_TONES是本示例的精髓:蜂鸣器只能发单音,无法播放完整波形。开启该模式后,encode()只产生一系列Tone(每个元素是 FFT bin 索引),主循环据此逐个调用 Arduino 的tone()函数发声。协议定义中每个音调的频率与时长由以下公式给出(ggwave.h):
freq_hz = (protocol.freqStart + Tone) * hzPerSample duration_ms = protocol.txDuration_ms(samplesPerFrame, sampleRate)hzPerSample在源码中计算为m_sampleRate/m_samplesPerFrame(src/ggwave.cpp),即本示例中的 46.875 Hz/bin。
协议选择:内存与鲁棒性的权衡
草图使用disableAll()+toggle()逐项开启所需协议(arduino-rx.ino):
// TX:只保留 MT_FASTEST(单音、最快协议) GGWave::Protocols::tx().disableAll(); GGWave::Protocols::tx().toggle(GGWAVE_PROTOCOL_MT_FASTEST, true); // RX:默认只开 DT_FASTEST 与 MT_FASTEST GGWave::Protocols::rx().disableAll(); #ifdef LONG_RANGE GGWave::Protocols::rx().toggle(GGWAVE_PROTOCOL_DT_FAST, true); #endif GGWave::Protocols::rx().toggle(GGWAVE_PROTOCOL_DT_FASTEST, true); #ifdef LONG_RANGE GGWave::Protocols::rx().toggle(GGWAVE_PROTOCOL_MT_FAST, true); #endif GGWave::Protocols::rx().toggle(GGWAVE_PROTOCOL_MT_FASTEST, true);协议族定义在 ggwave.h,本示例只用到单音(MT,mono-tone)与双音(DT,dual-tone)系列,其结构字段为:
struct Protocol { const char * name; // 协议名,如 "[MT] Fastest" int16_t freqStart; // 最低频率的 FFT bin 索引 int8_t framesPerTx; // 传输一个数据块所需帧数 int8_t bytesPerTx; // 一个数据块的字节数 int8_t extra; // 2 表示单音协议,1 表示双音协议 bool enabled; };- MT 系列(extra=2):每个时刻只发一个音调,每个音调携带 4 bit,一个数据块
nTones = 2*bytesPerTx/2 = 1个音调,适合低功耗蜂鸣器,抗干扰性更好; - DT 系列(extra=1):同时发两个音调(6 个 4-bit chunk 中的 3 字节),吞吐更高;
framesPerTx越小(Fastest=3)传输越快,但每帧携带的比特更多,鲁棒性相对降低;LONG_RANGE模式下启用DT_FAST/MT_FAST这类更慢但更稳的协议,代价是解码内存与时长增加。
协议表里还有注释掉的可选项,包括可听域(AUDIBLE)与超声(ULTRASOUND)系列;若要在 Arduino UNO 等资源更受限的板子上使用,可在编译时定义GGWAVE_CONFIG_FEW_PROTOCOLS(见 ggwave.h)裁剪掉这些协议以节省内存。
调优提示:若蜂鸣器无法良好发声在 1–2 kHz 单音范围,可像 arduino-tx 示例 那样把
freqStart整体上移,例如+48个 bin(48 × 46.875 Hz ≈ +2250 Hz)。此时接收端(如 Waver 应用)必须做相同偏移,否则无法解调。
内存预算:先查询后分配
草图的巧妙之处在于两阶段初始化(arduino-rx.ino):
ggwave.prepare(p, false); // allocate=false:只计算所需内存,不分配 Serial.print(F("Required memory by the ggwave instance: ")); Serial.print(ggwave.heapSize()); // 打印所需字节数 Serial.println(F(" bytes")); ggwave.prepare(p, true); // 真正分配并初始化prepare(parameters, allocate=false)的语义在 ggwave.h 中有明确说明:所有内存缓冲都在prepare()中一次性分配,之后不再发生任何动态分配。先以allocate=false查询heapSize(),再正式初始化,方便在串口监视器中评估内存余量——这是嵌入式场景下的标准实践。源码中缓冲区大小由payloadLength、启用的协议数量等共同决定(src/ggwave.cpp),因此只启用需要的协议能显著降低内存占用。
五、主循环:解码、取数、显示与回传
逐帧解码与实时性监控
主循环以samplesPerFrame为单位消费缓冲区(arduino-rx.ino):
while (qsize >= samplesPerFrame) { auto tStart = millis(); ggwave.decode(sampleBuffer + qhead, samplesPerFrame*kSampleSize_bytes); qsize -= samplesPerFrame; qhead += samplesPerFrame; if (qhead >= qmax) qhead = 0; // 环形回绕 auto tEnd = millis(); if (++niter % 10 == 0) { Serial.println(tEnd - tStart); // 每次 decode() 耗时 if (tEnd - tStart > 1000*(float(samplesPerFrame)/sampleRate)) { Serial.println(F("Warning: decode() took too long to execute!")); } } ... }实时性判据:decode()单次耗时必须小于一帧音频时长。本示例中128/6000 ≈ 21.3 ms,若耗时超限,主循环将跟不上采集速度,缓冲区会持续溢出(ISR 中err累加),此时应降低协议数量或提高性能。草图每 10 次解码打印一次耗时,方便在串口绘图器中观察。
取出解码数据
解码结果通过"消费式"API 取出(arduino-rx.ino):
GGWave::TxRxData result; nr = ggwave.rxTakeData(result); // 返回解码字节数 if (nr > 0) { Serial.print(F("Received data with length ")); Serial.print(nr); // 应等于 p.payloadLength Serial.println(F(" bytes:")); Serial.println((char *) result.data()); strcpy(resultLast, (char *) result.data()); // 保存最近一次结果 tLastReceive = tEnd; }rxTakeData()与rxData()/rxSpectrum()等 Rx 方法在 ggwave.h 中定义:rxTakeData消费并返回已解码数据,返回长度即载荷字节数(固定长度模式下应等于payloadLength)。解码是流式的——每次decode()只分析传入的一帧,内部维护帧历史(spectrumHistoryFixed、amplitudeHistory等,见 ggwave.h),跨帧累积足够数据后才产出结果,这正是主循环需要持续喂帧的原因。
OLED 频谱可视化(可选)
开启DISPLAY_OUTPUT宏后,每次成功取到频谱就刷新屏幕(arduino-rx.ino):
static GGWave::Spectrum rxSpectrum; if (ggwave.rxTakeSpectrum(rxSpectrum) && t > 2000) { const bool isNew = t - tLastReceive < 2000; // 2 秒内收到新数据视为"新" digitalWrite(kPinLED0, isNew ? HIGH : LOW); // 新数据点亮板载 LED display.clearDisplay(); display.setTextSize(isNew ? 2 : 1); // 新数据用大字号 display.println(resultLast); // 顶部显示最近接收的文本 // 绘制 64 个 bin 的实时频谱柱状图 const int nBin0 = 16, nBins = 64, dX = SCREEN_WIDTH/nBins; float smax = 0.0f; for (int x = 0; x < nBins; x++) smax = std::max(smax, rxSpectrum[nBin0 + x]); smax = smax == 0.0f ? 1.0f : 1.0f/smax; for (int x = 0; x < nBins; x++) { const int x0 = x*dX, x1 = x0 + dX; const int y = (int)(0.75f*SCREEN_HEIGHT*(rxSpectrum[nBin0 + x]*smax)); display.fillRect(x0, SCREEN_HEIGHT - y, dX, y, SSD1306_WHITE); } display.display(); }这段代码展示了 ggwave 的实时频谱能力:rxTakeSpectrum()返回 FFT 幅度谱,配合fillRect即可在 128x32 的 OLED 上画出数据音频的频域形态——既是调试利器,也能直观验证"声音里真的有数据"。
按键触发回传
主循环还处理按键去抖与回传(arduino-rx.ino):
int but0 = digitalRead(kPinButton0); if (but0 == LOW && but0Prev == HIGH) { // 下降沿触发 PDM.end(); // 发送前先停掉麦克风 delay(500); // 留出余音衰减时间 send_text(ggwave, kPinSpeaker, resultLast, GGWAVE_PROTOCOL_MT_FASTEST); if (!PDM.begin(channels, sampleRate)) { // 恢复采集 Serial.println(F("Failed to start PDM!")); while (1); } but0Prev = LOW; }send_text()是回传的核心(arduino-rx.ino):
void send_text(GGWave & ggwave, uint8_t pin, const char * text, GGWave::TxProtocolId protocolId) { ggwave.init(text, protocolId); // 设置待发送文本与协议 ggwave.encode(); // 生成音调序列(TX_ONLY_TONES 模式) const auto & protocol = GGWave::Protocols::tx()[protocolId]; const auto tones = ggwave.txTones(); // 音调列表(bin 索引) const auto duration_ms = protocol.txDuration_ms(ggwave.samplesPerFrame(), ggwave.sampleRateOut()); for (auto & curTone : tones) { const auto freq_hz = (protocol.freqStart + curTone)*ggwave.hzPerSample(); tone(pin, freq_hz); // Arduino tone() 产生方波单音 delay(duration_ms); } noTone(pin); digitalWrite(pin, LOW); }注意发送与接收使用同一工作采样率 6000 Hz,txTones()返回的每个 bin 索引经hzPerSample换算成实际频率,由tone()驱动蜂鸣器;txDuration_ms()计算每帧的时长(framesPerTx × 1000 × samplesPerFrame / sampleRate,见 ggwave.h)。发送期间必须暂停 PDM 采集(PDM.end()),否则蜂鸣器声音会被自身麦克风采进去形成回授。
六、宏开关:DISPLAY_OUTPUT 与 LONG_RANGE
草图通过两个编译期宏提供功能裁剪,位于文件头部(arduino-rx.ino):
//#define DISPLAY_OUTPUT 1 // 启用 SSD1306 显示输出(需接线 I2C 屏) //#define LONG_RANGE 1 // 启用长距离传输:协议更慢更稳,但更耗内存- DISPLAY_OUTPUT:启用后包含
Adafruit_GFX/Adafruit_SSD1306库并初始化 128x32 屏;无屏场景下靠串口监视器即可观察解码结果(草图的注释明确说明这一点); - LONG_RANGE:启用后
payloadLength降为 8 字节、RX 增加DT_FAST与MT_FAST两个较慢协议。原因在草图注释中给出——FAST 系列协议解码所需内存约为 Fastest 的 2 倍,因此必须缩小载荷以腾出内存(getECCBytesForLength(len)的实现为len < 4 ? 2 : max(4, 2*(len/5)),见 src/ggwave.cpp,载荷变短时 ECC 开销也随之减少)。更慢的协议意味着每个音调持续更长时间、能量更集中,抗噪与抗衰减能力更强,适合室内远距离或嘈杂环境。
七、与 Waver 应用联调测试
草图注释提供了现成的快速验证方案(arduino-rx.ino):使用免费的Waver(ggwave 官方演示应用,仓库内示例见 examples/waver)从手机端发送数据:
- 在 Waver 设置中开启"Fixed-length"(固定长度)选项;
- 把固定字节数设置为与草图
payloadLength一致(默认 16,LONG_RANGE 模式为 8); - 选择当前草图 RX 已启用的协议(默认
DT_FASTEST/MT_FASTEST,LONG_RANGE 时还包括DT_FAST/MT_FAST)。
联调时注意:若在 arduino-tx 中对freqStart做过偏移,接收端也必须做相同偏移,保证收发协议参数一致。原示例的演示视频展示了手机发、板子收、OLED 显示、再按键回传的完整链路,读者可据此复现。
八、底层原理印证:从源码看 FSK 与纠错
整个示例背后是 ggwave 的 FSK 调制方案(详见 README.md 的 "Technical details"):
- 调制:数据先按 4-bit 切块,每个时刻用 6 个音调承载 3 字节(每音调对应一个 4-bit chunk);96 个等间隔频率分布在 4.5 kHz 范围内,间隔
dF = 46.875 Hz,非超声协议基频F0 = 1875 Hz——这正好解释了本示例把工作采样率设为 6000 Hz、帧长 128 的原因(6000/128 = 46.875 Hz/bin); - 纠错:原始数据先经 Reed-Solomon 编码(源码位于 src/reed-solomon,ECC 字节数由载荷长度按
2*(len/5)规则决定),接收端对解调出的比特做 RS 解码还原; - 解码流程:变长模式下接收端先侦听起始/结束声音标记(
kDefaultMarkerFrames = 16),再对中间音频做 FFT 提取频率、映射回比特;而固定长度模式(payloadLength > 0)跳过标记、直接跨帧累积频谱历史解码(decode_fixed(),见 ggwave.h),这也是为什么固定长度模式更快、也更适合微控制器的原因。
九、扩展参考
- 纯发送端示例 arduino-tx:基于 Arduino Uno + 蜂鸣器,展示了相同的
send_text思路与freqStart偏移调优,是理解本示例回传逻辑的对照物; - 同类的 rp2040-rx:基于 Raspberry Pi Pico(RP2040),采用不同的模拟麦克风方案(见其
mic-analog.cpp),可作为了解多种采集路径的参考; - esp32-rx:ESP32 上的收发示例,适合需要无线与声音混合通信的场景。
十、常见问题速查
| 现象 | 可能原因与对策 |
|---|---|
串口打印ERROR与错误码 | PDM 回调来不及消费(err+10)或qmax非块大小整数倍(err+1)。增大qmax、减少启用的协议数 |
Warning: decode() took too long | decode()超过一帧时长(约 21 ms)。只保留必要协议,或改用samplesPerFrame更小的配置 |
| 收不到数据 | 确认 Waver 开了 "Fixed-length" 且字节数等于payloadLength;确认协议与草图 RX 启用的协议一致;检查 OLED 地址(0x3C/0x3D) |
| 蜂鸣器回传对端收不到 | 蜂鸣器频响不佳时按freqStart += 48上移基频,并确保接收端同步偏移;发送前确认已PDM.end()暂停采集 |
| 内存不足 | 启用GGWAVE_CONFIG_FEW_PROTOCOLS裁剪协议,或缩小payloadLength(LONG_RANGE 即此思路) |
- 通信
- 物联网
- 嵌入式
【免费下载链接】ggwave
Tiny>项目地址:https://gitcode.com/GitHub_Trending/gg/ggwave
相关推荐
ggwave 数据声波传输实战:Arduino Uno 蜂鸣器发射端 arduino-tx 示例深度解析
ggwave 数据声波传输实战:Arduino Uno 蜂鸣器发射端 arduino tx 示例深度解析 ggwave 是一款"数据通过声音传输"(data o
通信物联网嵌入式ggwave arduino-rx-web:用 Web 页面实时接收 Arduino 蜂鸣器声音数据的完整实战
ggwave arduino rx web:用 Web 页面实时接收 Arduino 蜂鸣器声音数据的完整实战 导读 arduino rx web 是 ggwa
通信物联网嵌入式Shopify 渠道 Webhook 实战:用 Flue 将订单安全分发给本地 Agent 实例
Shopify 渠道 Webhook 实战:用 Flue 将订单安全分发给本地 Agent 实例 导读 本文以 Flue 仓库中的 examples/shopi
通信物联网嵌入式