FHT 音频频谱可视化 - 打造绚丽 LED 音乐灯光秀
核心组件清单 (BOM)
| 组件 | 规格/型号 | 数量 | 关键参数 |
|---|---|---|---|
| MCU | Arduino Nano R3 (ATmega328P) | 1 | 16MHz, 2KB RAM, 32KB Flash |
| LED 矩阵 | WS2812B 8×32 RGB (256 颗) | 1 | 单线控制, GRB 顺序, 800kHz |
| 音频输入 | 立体声 RCA 母座 + 3.5mm 转 RCA 线 | 1 | 左右声道合并单声道输入 |
| 前置放大/偏置电路 | 见原理图 | 1 | 将音频信号偏移至 0-5V 范围 |
| 关键无源器件 | 1N4007 二极管、1000µF/100nF 电容、390Ω/10kΩ/4.75kΩ/100kΩ 电阻、12mm 按键×2 | 各若干 | 电源保护、信号调理、按键消抖 |
| 原型板 | 4×6cm 万用板 | 1 |
核心技术:FHT 与 FFT 的区别
FHT (Fast Hartley Transform,快速哈特莱变换) 与 FFT 同为时频域转换算法,但 FHT 仅处理实数数据,而 FFT 需处理复数。优势在于:
- 内存占用减半:单数组采样,无需实部/虚部双数组
- 计算速度提升 2 倍:避免复数运算与浮点对数运算,改用查表法
- 代价:频率分辨率为采样点数一半 (N/2),两端精度略低,但用于灯光特效绰绰有余
本项目采用 OpenMusicLabs FHT 库,FHT_N = 128 采样点,输出 64 个频率箱,映射到 32 列显示,每列取 2 个箱平均。
电路设计要点
音频前端电路
- 分压偏置:两个 100kΩ 电阻分压提供 2.5V 虚拟地,音频信号叠加在其上
- 保护二极管:1N4007 限幅保护 ADC 引脚防过压
- 去耦电容:100nF 滤高频噪声,1000µF 稳定 WS2812B 供电
- 数据线保护:390Ω 串联电阻抑制 WS2812B 数据线过冲
供电设计
- WS2812B 256 颗全白最大电流 ~15A (5V),实际特效平均电流 1-3A
- 建议使用 5V/5A 以上开关电源,电源正负极分别在矩阵首尾并联供电,减少压降
- Arduino 可通过 USB 或电源 5V 供电,与矩阵共地
功能特性
- 实时频谱瀑布显示:32 列 × 8 行,峰值保持回落动画
- 8 种配色方案:渐变、分段、行变色、列变色、意大利旗、美旗、自定义等
- 按键交互:短按切换配色、长按调节亮度(8 级)
- EEPROM 掉电记忆:自动保存/恢复配色与亮度设置
- EQ 均衡器修正:内置 32 段预置 EQ 曲线补偿人耳听感/麦克风频响
- 启动显示当前设置:开机短暂显示配色编号与亮度等级
完整代码 (FHT 版)
/* FHT Audio Spectrum Visualizer - Janux 2020
* MIT License | Based on OpenMusicLabs FHT Library
* Hardware: Arduino Nano, WS2812B 8x32 Matrix
*/
#define LIN_OUT 1
#define FHT_N 128
#include <FHT.h>
#define XRES 32
#define YRES 8
#define LED_PIN 6
#define NUM_LEDS (XRES * YRES)
#include <Adafruit_NeoPixel.h>
#define COLOR_PIN 5
#define BRIGHT_PIN 10
byte displayColor = 0;
byte brightness = 1;
#include <EEPROM.h>
#define CONFIG_START 32
#define CONFIG_VERSION "VER01"
typedef struct { char version[6]; byte displayColor; byte brightness; } config_t;
config_t CONFIG = { CONFIG_VERSION, 0, 1 };
byte yValue;
int peaks[XRES];
byte btnState = HIGH, lastBtnState = LOW;
unsigned long lastDebounce = 0;
const unsigned long debounceDelay = 100;
int dataAvgs[XRES];
Adafruit_NeoPixel strip = Adafruit_NeoPixel(NUM_LEDS, LED_PIN, NEO_GRB + NEO_KHZ800);
// EQ 补偿曲线 (32 段)
byte eq[32] = { 60,65,70,75,80,85,90,95,100,100,100,100,100,100,100,100,
100,100,100,100,100,100,100,100,115,125,140,160,185,200,200,200 };
bool EQ_ON = true;
// 8 套配色方案 (每列 8 行颜色索引)
byte colors[][8] = {
{170,160,150,140,130,120,1,1}, // 0: 红橙黄绿渐变
{1,5,10,15,20,25,90,90}, // 1: 蓝青渐变
{90,85,80,75,70,65,1,1}, // 2: 绿黄红逆序
{90,90,90,30,30,30,1,1}, // 3: 紫粉混合
{170,160,150,140,130,120,110,0}, // 4: 自定义(0=白)
{0}, {0}, {0} // 5:按列变色 6:按行变色 7:国旗模式
};
// 自定义字符位图 (用于显示设置数字)
byte charBitmap[] = {
0x1C,0x10,0x10,0x10,0x10,0x1C, // 0
0x08,0x18,0x08,0x08,0x08,0x1C, // 1
0x0C,0x12,0x04,0x08,0x10,0x1E, // 2
0x0C,0x12,0x02,0x06,0x12,0x0C, // 3
0x10,0x10,0x10,0x14,0x1E,0x04, // 4
0x1E,0x10,0x1E,0x02,0x12,0x0C, // 5
0x1E,0x10,0x10,0x1E,0x12,0x1E, // 6
0x1E,0x12,0x1E,0x1E,0x02,0x04, // 7
0x08,0x08,0x08,0x0C,0x12,0x0C, // 8
0x12,0x12,0x0C,0x1C,0x12,0x1C // 9
};
void setup() {
strip.begin();
// ADC 设置: 自由运行模式, 预分频 32 (16MHz/32=500kHz 采样率)
ADCSRA = 0b11100101; // ADEN=1, ADSC=1, ADATE=1, ADIF=1, ADIE=0, ADPS=101(32)
ADMUX = 0b00000000; // REFS=00(外部参考), ADLAR=0(右对齐), MUX=0000(A0)
if (loadConfig()) { displayColor = CONFIG.displayColor; brightness = CONFIG.brightness; }
strip.setBrightness(brightness * 24 + 8); // 映射 0-7 -> 8-176
showSettings(3, true); // 开机显示配色+亮度
}
void loop() {
while (1) {
sampling(); // 采样 128 点
rearrangeFHT(); // FHT 后重排映射到 32 列
sendToDisplay(); // 点亮 LED
colorChange(); // 检查配色按键
brightnessChange(); // 检查亮度按键
delay(10); // 降低刷新率减少闪烁
}
}
void sampling() {
for (int i = 0; i < FHT_N; i++) {
while (!(ADCSRA & 0x10)); // 等待 ADIF 置位
ADCSRA = 0b11110101; // 清 ADIF (写 1 清零)
byte m = ADCL;
byte j = ADCH;
int val = (j << 8) | m; // 10 位 ADC 结果
val -= 0x0200; // 转有符号 (以 512 为零点)
val <<= 6; // 扩展为 16 位有符号
fht_input[i] = val / 8; // 压缩至 8 位有符号输入 FHT
}
fht_window(); // 加窗
fht_reorder(); // 重排
fht_run(); // 变换
fht_mag_lin(); // 线性幅度输出
}
void rearrangeFHT() {
int step = (FHT_N / 2) / XRES; // 64/32 = 2
for (int c = 0, i = 0; i < FHT_N/2; i += step, c++) {
dataAvgs[c] = 0;
for (int k = 0; k < step; k++) dataAvgs[c] += fht_lin_out[i + k];
dataAvgs[c] /= step;
}
}
void sendToDisplay() {
for (int x = 0; x < XRES; x++) {
if (EQ_ON) dataAvgs[x] = dataAvgs[x] * eq[x] / 100;
dataAvgs[x] = constrain(dataAvgs[x], 0, 80);
yValue = map(dataAvgs[x], 0, 80, 0, YRES);
peaks[x] = max(peaks[x] - 1, yValue); // 峰值保持+回落
yValue = peaks[x];
setColumn(x, yValue);
}
strip.show();
}
// 蛇形布局坐标映射: DIN 在右下角, 第一列向上, 第二列向下...
int getLedIndex(byte x, byte y) {
x = XRES - 1 - x; // 镜像翻转, 使频谱从左向右
if (x & 1) return (x + 1) * YRES - y - 1; // 奇列向下
else return (x + 1) * YRES - YRES + y; // 偶列向上
}
void setColumn(byte x, byte y) {
for (byte row = 0; row < YRES; row++) {
int idx = getLedIndex(x, row);
if (row < y) {
uint32_t color;
switch (displayColor) {
case 4: color = (colors[4][row] == 0) ? strip.Color(255,255,255) : Wheel(colors[4][row]); break;
case 5: color = Wheel(x * 16); break;
case 6: color = Wheel(row * y * 3); break;
case 7: // 国旗模式
if (row < YRES - 2) color = (x & 1) ? strip.Color(0,0,255) : strip.Color(255,0,0);
else color = strip.Color(255,255,255);
break;
default: color = Wheel(colors[displayColor][row]);
}
strip.setPixelColor(idx, color);
} else {
strip.setPixelColor(idx, 0);
}
}
}
// 色轮函数: 0-255 映射 R->G->B->R
uint32_t Wheel(byte pos) {
pos = 255 - pos;
if (pos < 85) return strip.Color(255 - pos*3, 0, pos*3);
if (pos < 170) { pos -= 85; return strip.Color(0, pos*3, 255 - pos*3); }
pos -= 170; return strip.Color(pos*3, 255 - pos*3, 0);
}
void colorChange() {
byte reading = digitalRead(COLOR_PIN);
if (reading == HIGH && lastBtnState == LOW && millis() - lastDebounce > debounceDelay) {
displayColor = (displayColor + 1) % 8;
showSettings(1, true);
saveConfig();
lastDebounce = millis();
}
lastBtnState = reading;
}
void brightnessChange() {
byte reading = digitalRead(BRIGHT_PIN);
if (reading == HIGH && lastBtnState == LOW && millis() - lastDebounce > debounceDelay) {
brightness = (brightness + 1) % 8;
strip.setBrightness(brightness * 24 + 8);
showSettings(2, true);
saveConfig();
lastDebounce = millis();
}
lastBtnState = reading;
}
void showSettings(byte mode, bool show) {
if (!show) return;
strip.clear();
if (mode == 1 || mode == 3) { drawChar(0, 0); drawChar(displayColor + 1, 5); }
if (mode == 2 || mode == 3) { drawChar(9, XRES - 9); drawChar(brightness + 1, XRES - 4); }
delay(1000);
strip.clear();
}
void drawChar(byte val, byte pos) {
for (int x = 4; x >= 0; x--) {
for (int y = 5; y >= 0; y--) {
if ((charBitmap[val * 6 + 5 - y] >> x) & 0x01) {
strip.setPixelColor(getLedIndex(4 - x + pos, y + 1), Wheel((pos > 10) * 170));
strip.show();
}
}
}
}
int loadConfig() {
for (int i = 0; i < 5; i++) if (EEPROM.read(CONFIG_START + i) != CONFIG_VERSION[i]) return 0;
for (unsigned int i = 0; i < sizeof(CONFIG); i++) *((char*)&CONFIG + i) = EEPROM.read(CONFIG_START + i);
return 1;
}
void saveConfig() {
CONFIG.displayColor = displayColor; CONFIG.brightness = brightness;
for (unsigned int i = 0; i < sizeof(CONFIG); i++) EEPROM.write(CONFIG_START + i, *((char*)&CONFIG + i));
}
制作与调试建议
- 音频输入电平:手机/电脑音量调至 70-80%,避免削波或信号过弱
- EQ 调整:根据音乐类型/麦克风特性修改
eq[]数组,或添加串口命令动态调整 - 灵敏度微调:修改
constrain(..., 0, 80)中的上限值,或调整map目标范围 - 供电稳定:必须在矩阵首尾并联粗线供电,并在 Arduino 靠近处并联 1000µF 电解电容
- 按键消抖:代码已含软件消抖,硬件可并联 100nF 电容
扩展创意
- 加入麦克风模块 (MAX9814/INMP441) 实现免布线拾音
- 移植到 ESP32,增加 WiFi/BLE 控制、Web 配置界面、多设备同步
- 设计 3D 打印外壳,集成电源开关、旋钮编码器替代按键
- 增加频谱数据串口/蓝牙输出,配合 Processing/Python 可视化
原理图与效果图
详见项目附件:原理图 (含音频前端、WS2812B 供电、按键电路)、实物接线图、运行效果照片。
制造工艺
- 32 频段 Arduino 音频频谱分析仪:线性与伪对数双模式
- 智能缺水报警器 – DIY植物土壤湿度监测装置
- 意大利语单词时钟:Arduino Nano + NeoPixel 144 灯珠 + LCD + RTC 打造
- 基于Arduino MKR Fox 1200构建可靠Sigfox千瓦时电表
- 蓝牙温度监测仪:Arduino Nano + NTC 热敏电阻 + HC-06 无线传输
- 非接触手势门锁:APDS-9960 + Arduino 实现安全门禁
- Arduino Nano 多功能伴侣套件 —— 电阻/电容测量、二极管测试、RTC时钟一体化
- Arduino Micro/Leonardo USB MIDI 适配器套件 —— 完整元件清单与固件指南
- 为Arduino构建隔离模拟输入电路
- RGB 32 频段音频频谱可视化器:Arduino Nano 驱动 WS2812B 8×32 LED 矩阵
- 两个电阻自制 Arduino UNO 视频输出线(RCA/TVout)
- Arduino UNO 精准视觉反应时间测试仪