公司动态

基于FFT与WS2812B的音乐频谱可视化:嵌入式音频处理实践

📅 2026/8/19 23:35:37
基于FFT与WS2812B的音乐频谱可视化:嵌入式音频处理实践
1. 项目概述当音乐可视化遇上嵌入式开发如果你和我一样既喜欢捣鼓嵌入式硬件又对音乐和灯光效果着迷那么“用PDM麦克风、NeoPixel灯带和Seeed Studio的Xiao nRF52840 Sense开发板来制作一个音乐可视化器”这个项目绝对会让你兴奋不已。这不仅仅是一个简单的“灯随音乐闪烁”的玩具而是一个融合了音频信号处理、实时数据采集、嵌入式系统编程和色彩美学设计的综合性实践项目。它解决的问题非常直接如何将无形的音乐节奏和强度实时地、美观地转化为有形的、动态的灯光色彩变化。无论是想为你的桌面增添一个酷炫的氛围灯还是想深入学习嵌入式音频处理和实时系统这个项目都是一个绝佳的起点。这个项目的核心在于Xiao nRF52840 Sense这块功能强大的微型开发板。它集成了PDM数字麦克风可以直接采集环境声音省去了外接麦克风模块的麻烦。其搭载的nRF52840芯片拥有充足的算力和内存足以应对实时的音频FFT快速傅里叶变换计算。而NeoPixel或WS2812B灯带则是灯光效果的绝佳载体每个LED都可以独立编程控制颜色和亮度为我们实现复杂的频谱可视化效果提供了无限可能。整个项目的流程可以概括为麦克风采集声音 - 开发板进行FFT分析将时域信号转换为频域信号 - 根据不同频段的能量值映射生成对应的灯光颜色和动画 - 通过GPIO口驱动灯带显示。接下来我将为你详细拆解从硬件连接到算法实现的每一个环节并分享我在调试过程中踩过的坑和总结的经验。2. 硬件选型与核心组件解析2.1 主角Xiao nRF52840 Sense开发板深度剖析选择Xiao nRF52840 Sense作为核心绝非偶然。首先它的“Sense”后缀名副其实板载了包括PDM麦克风、IMU六轴、光照传感器等多种传感器为我们这个项目提供了“开箱即用”的音频采集能力。其核心nRF52840 SoC基于ARM Cortex-M4F内核主频64MHz拥有1MB Flash和256KB RAM性能对于进行实时音频FFT计算绰绰有余。更重要的是它支持蓝牙5.0这为项目后续扩展无线控制或音频流传输提供了可能虽然本项目聚焦有线可视化。板载的MP34DT05 PDM麦克风是一个数字麦克风它直接输出PDM脉冲密度调制信号而不是模拟信号。这意味着我们无需额外的ADC模数转换器但需要在代码中通过I2S/PDM外设进行解码将其转换为我们熟悉的PCM脉冲编码调制音频数据。这个集成设计极大地简化了硬件连接但也对软件驱动提出了特定要求。2.2 光影艺术家NeoPixel/WS2812B灯带详解NeoPixel是Adafruit对WS2812B智能LED的商标称呼本质上我们使用的都是基于WS2812B或SK6812的灯带。每个LED内部都集成了驱动芯片只需要一根数据线Din进行控制。它采用单线归零码通信协议通过精确控制高电平的持续时间来传递数据这使得我们可以用单片机的一个GPIO引脚控制成百上千个灯珠实现复杂的动态效果。对于本项目灯带的选择有几个关键参数灯珠密度常见的有30灯/米、60灯/米、144灯/米。密度越高显示的频谱“柱状图”越精细但同时对单片机的内存和刷新率要求也越高。对于入门30或60灯/米是完全足够的。供电电压常见5V。务必注意长灯带尤其是高密度工作时电流巨大绝不能直接从开发板取电必须使用独立的外部5V电源如5V/3A以上的电源适配器为灯带供电同时需要将灯带的GND地线与开发板的GND相连以确保信号电平一致。信号方向灯带具有方向性数据从Din输入从Dout输出给下一个灯珠。接线时务必确认方向。2.3 硬件连接与供电方案正确的连接是成功的一半错误的连接可能直接烧毁设备。以下是接线示意图和详细说明外部5V电源 --- [灯带 VCC] | 外部5V电源 GND --- [灯带 GND] --- [Xiao nRF52840 Sense GND] | [Xiao nRF52840 Sense D6] --- [灯带 Din]接线要点与避坑指南独立供电是铁律如前所述务必为灯带准备独立的5V电源。开发板的USB供电能力有限通常500mA无法驱动哪怕是一小段高亮度的灯带。共地是关键外部电源的GND必须与开发板的GND连接在一起。这是为了建立共同的参考零电位确保数据信号能被灯带正确识别。不共地会导致信号紊乱灯带显示异常或完全不工作。信号引脚选择理论上任何支持数字输出的GPIO都可以。我选择D6仅仅是因为它在Xiao板子上位置方便。在代码中需要对应修改。电容保驾护航在灯带的VCC和GND之间并联一个470-1000μF的电解电容可以有效地平滑电源波动防止灯带在整体亮度快速变化时因瞬间电流过大而导致电源电压跌落进而引起单片机复位或灯带颜色异常。这是一个非常实用但常被忽略的技巧。3. 软件开发环境搭建与核心库介绍3.1 开发环境配置Arduino IDE vs PlatformIO对于Xiao nRF52840 Sense我们主要有两种开发环境选择Arduino IDE和PlatformIO通常作为VSCode插件。我强烈推荐PlatformIO原因如下库管理强大它能自动处理复杂的库依赖关系。本项目需要用到FFT、NeoPixel驱动、可能还有音频处理库PlatformIO可以一键安装。项目结构清晰它强制要求标准的项目文件夹结构src,lib,include等便于代码管理。调试体验更佳与VSCode深度集成提供更好的代码补全、语法高亮和串口监视体验。当然如果你对Arduino IDE非常熟悉也可以使用。需要在“开发板管理器”中添加“Seeed nRF52 Boards”支持。无论哪种方式都需要安装针对nRF52840的板支持包。3.2 核心库依赖与作用解析我们的代码将建立在几个关键的库之上Adafruit NeoPixel或FastLED用于驱动WS2812B灯带。Adafruit_NeoPixel库简单易用FastLED库则功能更强大、性能更高提供了丰富的色彩数学和动画函数。本项目对性能要求较高我推荐使用FastLED。PDM或I2S库用于从板载麦克风读取音频数据。对于Xiao nRF52840 SenseSeeed的板支持包通常已经提供了PDM库的示例我们可以基于它进行修改。一个FFT库这是项目的算法核心。我们需要将时域的音频采样数据转换到频域。在嵌入式环境下一个轻量级、定点运算的FFT库是首选。例如Arduino的arduinoFFT库或者针对ARM Cortex-M系列优化的CMSIS-DSP库如果使用PlatformIO并配置了正确的框架可能可以直接调用。安装提示在PlatformIO的platformio.ini配置文件中你需要在[env:seeed_xiao_ble]或其他对应环境下的lib_deps部分添加这些库例如lib_deps fastled/FastLED ^3.6.0 arduino-libraries/PDM kosme/arduinoFFT ^1.6.14. 核心算法与程序设计思路拆解4.1 音频采集与预处理流程音频处理流水线的第一步是可靠地获取数据。PDM麦克风以固定采样率如16kHz持续输出数据。我们的程序需要初始化PDM设置采样率、增益、缓冲区大小。缓冲区大小是一个关键参数它决定了每次进行FFT计算的数据点数例如256点、512点。点数越多频域分辨率越高但计算量也越大延迟也会增加。双缓冲机制这是一个重要的编程技巧。设置两个缓冲区Buffer A和B。当PDM驱动程序填满Buffer A后会产生一个中断或回调函数此时我们交换缓冲区将Buffer A交给后续的FFT处理流程同时让PDM继续向Buffer B填充数据。这样可以确保音频采集是连续的不会因为处理数据而丢失新的音频样本。数据预处理从PDM获取的是原始的PCM样本通常是16位有符号整数。在进行FFT之前我们通常需要转换为浮点数方便FFT库计算。应用窗函数如汉宁窗Hanning Window。这是因为FFT假设我们处理的是无限长周期信号的一个片段直接截断会产生频谱泄漏能量扩散到其他频段。加窗可以减少泄漏效应。去除直流偏移计算缓冲区数据的平均值然后每个样本减去这个平均值。这可以消除信号中的直流分量防止其干扰低频段的频谱分析。4.2 FFT变换与频段能量映射这是项目的数学核心。FFT将长度为N的时域信号转换为N个复数对应着从0到采样率/2的N/2个频率分量的幅度和相位信息。对于音乐可视化我们通常只关心幅度能量。执行FFT调用FFT库函数对预处理后的N个样本进行计算得到N个复数结果。计算幅度谱对于前N/2个复数结果因为频谱是对称的计算每个复数的模magnitude[i] sqrt(re[i]*re[i] im[i]*im[i])。这个magnitude[i]就代表了对应频率分量的能量强度。频段划分Binning人耳对频率的感知是对数型的例如我们对100Hz到200Hz的变化与对1000Hz到2000Hz的变化感知到的“差异”程度类似。因此直接将线性分布的N/2个频点映射到灯带上是不直观的。我们需要将频谱划分为若干个频段例如低音、中低音、中音、中高音、高音每个频段覆盖一个对数增长的频率范围。方法根据灯带LED的数量如16个预先计算每个LED对应的频率范围。例如LED 0对应20Hz-100Hz超低频LED 1对应100Hz-250Hz以此类推直到最后一个LED对应8kHz-16kHz高频。能量聚合对于每个划分好的频段将该频段内所有FFT频点对应的magnitude值求和或取平均值作为该频段的最终能量值。4.3 能量值到灯光颜色与动画的映射策略这是艺术与编程结合的部分。如何将枯燥的能量数值变成赏心悦目的灯光能量标准化每个频段的能量值范围波动很大。我们需要将其映射到一个固定的范围如0.0到1.0。一种动态的方法是维护一个历史能量峰值并缓慢衰减peak[i] max(energy[i], peak[i] * 0.995)。然后用当前能量除以峰值得到标准化值level[i]。这样灯光就能自动适应环境音量的变化。颜色映射经典频谱仪使用HSV色彩空间。将level[i]映射到亮度Value而色相Hue可以根据频段索引固定或变化。例如低音用红色Hue0中音用绿色Hue120高音用蓝色Hue240形成彩虹频谱。单色能量柱固定一个颜色如蓝色用level[i]控制该LED的亮度形成高低起伏的柱状图。火焰/粒子效果将能量视为“热量”低能量显示为暗红色高能量显示为亮黄色甚至白色并让颜色向上“飘散”模拟火焰。动画与平滑直接使用原始level[i]更新灯光会显得非常跳跃和生硬。我们需要引入平滑处理例如使用一阶低通滤波器smoothedLevel[i] smoothedLevel[i] * 0.7 level[i] * 0.3。这个平滑系数0.7和0.3可以调整值越大动画越平滑但响应越迟缓。4.4 主程序循环与性能优化要点一个高效的主循环是实时可视化的保证。结构通常如下void loop() { // 1. 检查音频缓冲区是否就绪 if (audioBufferReady) { // 2. 处理音频缓冲区预处理 - FFT - 频段能量计算 processAudio(); // 3. 更新能量峰值和标准化水平 updateLevels(); // 4. 根据水平值计算每个LED的颜色 calculateColors(); // 5. 将颜色数据发送到灯带 FastLED.show(); // 6. 交换缓冲区准备下一次采集 swapAudioBuffers(); } // 可能还有一些非实时任务如读取按键、串口调试等 }性能陷阱FastLED.show()函数在驱动较长灯带时是一个阻塞调用可能需要几毫秒到十几毫秒的时间。在这期间单片机无法处理其他事情可能导致PDM缓冲区溢出丢失音频数据。优化方法使用更快的GPIO如果支持和优化过的信号时序。减少灯带LED数量。确保loop()循环的整体速度远快于音频缓冲区的填充速度。例如如果采集256个样本需要16ms16kHz采样率那么你的loop()一次迭代最好能在10ms内完成。5. 分步实现与代码详解5.1 步骤一初始化硬件与库首先我们需要在setup()函数中完成所有初始化工作。顺序很重要。#include FastLED.h #include PDM.h // 使用Seeed提供的PDM库 #define NUM_LEDS 16 #define DATA_PIN 6 CRGB leds[NUM_LEDS]; // 音频相关定义 #define SAMPLES 256 // FFT点数必须是2的幂 #define SAMPLE_RATE 16000 // 采样率 short sampleBuffer[SAMPLES]; // PDM库通常使用short类型 volatile bool bufferReady false; void setup() { Serial.begin(115200); while (!Serial); // 等待串口连接仅用于调试 // 1. 初始化NeoPixel灯带 FastLED.addLedsWS2812B, DATA_PIN, GRB(leds, NUM_LEDS); FastLED.setBrightness(80); // 初始亮度设为80避免太刺眼 fill_solid(leds, NUM_LEDS, CRGB::Black); FastLED.show(); Serial.println(NeoPixel初始化完成。); // 2. 初始化PDM麦克风 PDM.onReceive(onPDMdata); // 设置数据接收回调函数 if (!PDM.begin(1, SAMPLE_RATE)) { // 1通道16kHz采样率 Serial.println(PDM麦克风初始化失败); while (1); } Serial.println(PDM麦克风初始化完成。); // 3. 初始化FFT和频段映射表此处省略具体初始化代码 initFFT(); initFrequencyBands(); }关键点PDM.onReceive设置了一个回调函数onPDMdata它会在PDM内部缓冲区满时被自动调用。我们在这个回调函数里填充我们的sampleBuffer并设置标志位。5.2 步骤二实现PDM数据回调与双缓冲这是保证音频连续采集的关键。// 双缓冲 #define BUFFER_COUNT 2 short audioBuffers[BUFFER_COUNT][SAMPLES]; volatile int currentBuffer 0; volatile bool buffersReady[BUFFER_COUNT] {false, false}; void onPDMdata() { // 这是一个在中断上下文中调用的函数务必保持简短 int available PDM.available(); PDM.read(audioBuffers[currentBuffer][0], SAMPLES * sizeof(short)); // 标记当前缓冲区已就绪 buffersReady[currentBuffer] true; // 切换到下一个缓冲区 currentBuffer (currentBuffer 1) % BUFFER_COUNT; // 预读下一个缓冲区保持采集连续 PDM.read(audioBuffers[currentBuffer][0], SAMPLES * sizeof(short)); }重要警告中断回调函数onPDMdata必须非常高效不能做复杂的数学运算或调用可能阻塞的函数如Serial.print。它的任务就是尽快把数据从PDM硬件搬移到我们的内存缓冲区然后返回。5.3 步骤三FFT计算与频段能量提取在loop()中我们检查是否有缓冲区就绪然后进行处理。#include arduinoFFT.h arduinoFFT FFT; void processAudioBuffer(short* buffer) { double vReal[SAMPLES]; double vImag[SAMPLES] {0}; // 1. 预处理转换为浮点加窗去直流 double avg 0; for (int i 0; i SAMPLES; i) { vReal[i] (double)buffer[i]; avg vReal[i]; } avg / SAMPLES; for (int i 0; i SAMPLES; i) { vReal[i] - avg; // 去直流 // 应用汉宁窗 double windowFactor 0.5 * (1 - cos(2 * PI * i / (SAMPLES - 1))); vReal[i] * windowFactor; } // 2. 执行FFT FFT.Windowing(vReal, SAMPLES, FFT_WIN_TYP_HANN, FFT_FORWARD); // 库可能内置窗函数 FFT.Compute(vReal, vImag, SAMPLES, FFT_FORWARD); FFT.ComplexToMagnitude(vReal, vImag, SAMPLES); // 3. 计算频段能量 (假设有NUM_LEDS个频段) int bands NUM_LEDS; float bandEnergy[bands] {0}; for (int b 0; b bands; b) { int startIdx freqBandStart[b]; // 预计算的每个频段起始FFT索引 int endIdx freqBandEnd[b]; // 预计算的每个频段结束FFT索引 for (int i startIdx; i endIdx; i) { // 通常只取前SAMPLES/2个点因为频谱对称 if (i SAMPLES/2) { bandEnergy[b] vReal[i]; } } bandEnergy[b] / (endIdx - startIdx 1); // 取平均能量 } // 4. 更新全局能量水平用于后续映射 updateEnergyLevels(bandEnergy); }freqBandStart和freqBandEnd数组需要你根据采样率16kHz、FFT点数256和你想要的频段划分如对数划分预先计算好。这是连接物理频率和视觉表现的关键桥梁。5.4 步骤四动态映射与灯光渲染最后将能量转化为色彩。float smoothedLevel[NUM_LEDS] {0}; float peaks[NUM_LEDS] {0.1}; // 初始化为小正值避免除零 void updateEnergyLevels(float* energy) { for (int i 0; i NUM_LEDS; i) { // 更新峰值带衰减 if (energy[i] peaks[i]) { peaks[i] energy[i]; } else { peaks[i] * 0.995; // 峰值缓慢衰减 } // 计算标准化水平并平滑 float rawLevel energy[i] / peaks[i]; rawLevel constrain(rawLevel, 0, 1); // 限制在0-1 smoothedLevel[i] smoothedLevel[i] * 0.8 rawLevel * 0.2; // 低通滤波 } } void renderLights() { for (int i 0; i NUM_LEDS; i) { float level smoothedLevel[i]; // 示例1彩虹频谱映射 byte hue map(i, 0, NUM_LEDS-1, 0, 160); // 将索引映射到HSV色相0-160是红色到青色 leds[i] CHSV(hue, 255, (byte)(level * 255)); // 示例2单色能量柱蓝色 // leds[i] CHSV(160, 255, (byte)(level * 255)); // 160是蓝色色相 } FastLED.show(); } void loop() { for (int i 0; i BUFFER_COUNT; i) { if (buffersReady[i]) { processAudioBuffer(audioBuffers[i]); buffersReady[i] false; // 处理完后清除标志 renderLights(); break; // 处理一个缓冲区后即可退出避免在一轮loop中处理过多 } } // 可以在这里添加非实时任务如调整亮度通过电位器或串口 }6. 调试技巧、性能优化与效果调校6.1 串口可视化不可或缺的调试工具在项目初期灯光效果不理想时我们很难知道是音频采集有问题、FFT计算错误还是映射逻辑不对。这时将中间数据通过串口发送到电脑用串口绘图器Serial Plotter查看是最高效的调试方法。void debugPrintLevels() { for (int i 0; i NUM_LEDS; i) { Serial.print(smoothedLevel[i]); Serial.print( ); } Serial.println(); }在renderLights()之前调用此函数然后在Arduino IDE的“工具”-“串口绘图器”中你就能看到每个频段能量水平的实时波形图。这能让你直观地判断麦克风是否在工作各个频段对声音的反应是否灵敏平滑滤波的效果如何6.2 性能瓶颈分析与优化如果你的灯光动画出现卡顿、闪烁或者音频断断续续可能是遇到了性能瓶颈。测量loop()时间在loop()开头和结尾用micros()记录时间计算单次循环耗时。它必须小于音频缓冲区填充时间例如256样本 16kHz 16ms。优化FFT计算尝试减少SAMPLES点数如从256降到128。这会降低频率分辨率但能大幅减少计算量。检查是否使用了浮点数FFT。对于Cortex-M4F浮点运算很快但如果内存紧张可以考虑使用定点数FFT库。优化灯带更新FastLED.show()的时间与LED数量成正比。减少NUM_LEDS能直接提升速度。尝试使用FastLED.delay()的替代方案但在这个实时性要求高的项目中通常我们直接控制show()的调用时机。内存使用检查栈溢出。大的数组如audioBuffers[2][256]应该定义为全局变量而不是在函数内部。6.3 效果调校让可视化更“悦耳悦目”硬件和代码跑通只是第一步调校出令人满意的效果才是艺术。灵敏度调整通过调整PDM.begin()中的增益参数或是在代码中对采样值进行缩放来改变麦克风灵敏度。环境嘈杂就调低环境安静或想捕捉细节就调高。频段划分调校这是最重要的调校之一。音乐中鼓点和贝斯集中在低频20-250Hz人声在中频250Hz-2kHz镲片和泛音在高频2kHz以上。你可以通过修改freqBandStart和freqBandEnd数组让某些LED更专注于你关心的频段。例如让前4个LED覆盖更宽的低频范围以突出节奏感。峰值衰减率与平滑系数peaks[i] * 0.995中的0.995决定了峰值下降的速度。值越大如0.998峰值保持越久整体电平越稳定但对突然的大声音反应会变慢。平滑系数0.8和0.2决定了动画的流畅度。增大旧值的权重0.8-0.9会更平滑但更“拖沓”增大新值的权重会更灵敏但更“跳跃”。你需要根据音乐类型和个人喜好找到平衡点。色彩映射创意不要局限于一种模式。可以尝试音量触发模式当总能量超过阈值时触发一个特殊的灯光特效如全屏闪烁或波浪。节奏检测通过监测低频能量的剧烈变化来检测鼓点让所有灯随鼓点闪烁。梯度色彩每个灯珠的颜色不是由索引决定而是由它的能量水平决定能量越高颜色越偏向暖色红、黄。7. 常见问题与故障排除实录在实际操作中你几乎一定会遇到下面这些问题。这里是我踩过坑后的解决方案汇总。7.1 灯带完全不亮或颜色异常这是最常见的问题90%以上源于硬件连接或供电。检查供电首先确认灯带是否使用了独立的外部5V电源并且电源功率足够建议至少5V/3A以上用于测试。用万用表测量灯带VCC和GND之间的电压在灯带全白最亮时电压不应低于4.8V否则说明电源带载能力不足。确认共地务必、务必、务必确保外部电源的GND与Xiao开发板的GND连接在了一起。这是最容易被忽略的一步。检查数据线确认数据线连接到了正确的GPIO引脚代码中定义的DATA_PIN并且接触良好。检查代码确认FastLED.addLeds中定义的芯片类型WS2812B和颜色顺序GRB与你的灯带一致。不同批次的灯带颜色顺序可能不同GRB, RGB, BGR等。如果颜色错乱比如显示红色时却亮了绿色大概率是颜色顺序错了。添加电容在灯带电源入口处并联一个470μF以上的电解电容正极接VCC负极接GND。这能解决因瞬间电流过大导致的复位问题。7.2 麦克风无反应或噪音巨大初始化失败检查PDM.begin()的返回值。确保麦克风没有被物理遮挡并且接线对于非集成麦克风正确。采样率与缓冲区确保SAMPLES是2的幂128, 256, 512等。采样率如16000要匹配PDM.begin()中的设置。电源噪音如果开发板通过电脑USB供电电脑本身的电源噪音可能被麦克风采集到。尝试用手机充电器或充电宝为开发板供电有时能显著降低底噪。软件增益在PDM.begin()后可以尝试调用PDM.setGain(gain)来调整增益gain值通常在20-80之间。检查回调函数确保onPDMdata回调函数被正确注册并且中断能正常触发。可以在回调函数里设置一个简单的标志如翻转一个LED看看它是否被周期性调用。7.3 灯光效果卡顿、闪烁或反应延迟性能超限使用Serial.print(micros())测量loop()周期和FastLED.show()的耗时。如果总时间接近或超过音频缓冲区时间样本数/采样率就会出问题。优化方法见6.2节。缓冲区溢出如果loop()太慢PDM驱动程序内部的缓冲区可能会被新数据覆盖导致丢失数据。确保你的处理速度够快或者尝试增大PDM库的内部缓冲区如果库支持配置。中断冲突FastLED.show()在某些实现中可能会禁用中断如果时间过长会影响PDM中断导致音频数据丢失。这是一个棘手的问题。如果遇到可以尝试1) 使用更少的LED2) 寻找FastLED库的“非阻塞”或“中断友好”模式3) 考虑使用其他驱动库。7.4 FFT结果看起来不对频谱全为零或没有变化检查原始数据在processAudioBuffer函数的第一步将buffer中的原始short值通过串口打印出来。对着麦克风说话或播放音乐看看数值是否有明显的变化范围如从-1000到1000。如果没有变化说明音频采集环节有问题。检查窗函数和去直流错误的窗函数应用或去直流操作可能会抵消掉所有信号。可以暂时注释掉窗函数和去直流代码看看频谱是否出现。FFT库配置确认你使用的FFT库函数输入输出参数正确。arduinoFFT库的Windowing函数可能会覆盖你的数据注意调用顺序。频段映射错误可能你的freqBandStart/End计算有误导致所有能量都被映射到了不正确的、超出人类听觉范围的频段。将bandEnergy数组打印出来看看各个频段的能量值是否随声音变化。这个项目从硬件连接到算法实现再到效果调优涉及了嵌入式开发的多个层面。最让我有成就感的一刻不是灯第一次亮起而是经过反复调校后灯光能精准而优雅地跟随音乐的旋律与节奏起舞那种软硬件协同创造出的动态美感是单纯编程或单纯做手工都无法比拟的。如果你在复现过程中遇到了上面没提到的问题或者有了更酷的灯光映射创意欢迎一起交流。