第一版做完机器能播歌但我自己很清楚那顶多算“能出声的播放器”离“Audio Station”还差一大截。没屏幕、没旋钮、没有任何音效本质上只是把WAV文件从SD卡送到codec里再推给耳机。这一版Part 2的目标很直接把Teensy这套东西真正推成一台桌面音频工具。我给这台机器加了IPS屏幕做实时频谱显示用两个旋转编码器控制音量和三频段EQ还把Line-in输入和本地播放信号混在了一起让它能像个正经监听设备一样干活。这篇按我的实际操作路径来写先讲整体设计思路和选型理由然后铺开硬件接线、Audio Library的软件链路搭建再拿频谱显示和三频段EQ两个功能做详细代码拆解最后把调试过程中踩过的坑整理成清单。文章适合已经玩过Teensy、想让音频项目更进一步的朋友也适合想直接照着接线的嵌入式入门者——代码结构我会尽量写清楚照着抄就能跑。1. 整体设计从“能出声”到“能干活”老规矩动手之前先想清楚要做什么。第一版那条信号链最短SD卡 → WAV播放 → SGTL5000 → 耳机口能响但没有任何可玩性。这次我不想一次塞十个功能只挑了四个最影响“设备感”的点视觉反馈、物理交互、实时音效、输入混音。1.1 这一版要解决的核心问题先把需求拆开看视觉反馈音量多少、当前EQ状态、频谱长什么样必须一眼能看到。我选了一块240x240的IPS屏用SPI口驱动。物理交互按个人习惯不想用手机App或者网页控制面板开机就能拧。两个EC11旋转编码器一个管音量一个管EQ参数按钮作为模式切换。实时音效给链路上加三频段EQ。低频、中频、高频三段独立调增益范围-12dB到12dB。输入混音手机或电脑的Line-in信号能进到同一套系统里和本地播放的WAV混合输出。等于一个基础版的桌面监听控制器。这四条里频谱显示和EQ是技术难点混音是链路设计的关键交互则是把前面所有东西串起来的“壳”。1.2 方案选型为什么还是Teensy 4.1 Audio Adapter第一版用的Teensy 4.1加官方Audio Adapter这块组合这一版继续沿用不是懒是确实没有更合适的替代方案。Teensy Audio Library最大的优势是它把所有音频处理单元抽象成了对象。SD卡播放、混音器、滤波器、FFT分析、I2S输入输出都像乐高积木一样可以互相连接代码层面就是一行AudioConnection的事。这套模型非常适合做这种信号流固定的桌面设备改起来也快。再说算力。Teensy 4.1是600MHz的Cortex-M7跑三个双二阶滤波器加一个1024点FFTCPU占用还不到两位数。要是换STM32或ESP32不是不行但要么音频生态不完整要么得自己从头写滤波器项目周期会拉得很长。还有一种选择是树莓派性能强生态多但带来的问题也很明显系统启动慢音频链路延迟大跑着Linux就没法保证“开机就能出声”的确定性。桌面音频工具最烦的就是启动后还要等操作系统就绪Teensy这边上电几百毫秒就能进入主循环。1.3 功能取舍哪些坚决不做想清楚不做什么比做什么更重要。这一版我主动砍掉了三个方向不做录音、不做无线、不做复杂菜单。录音功能要引入SD卡写文件和队列管理会显著增加AudioMemory和代码复杂度而且第一版没做后面再单独开一篇更合适。无线方案比如蓝牙音频Teensy处理器对这块支持比较弱硬要加得多搞一个模块反而破坏系统的简洁性。菜单方面我只做了“短按切换EQ频段”这一层状态不做二级菜单屏幕信息也只显示当前频段和增益值够用就行。做嵌入式项目最忌讳的是贪多先把核心链路跑通后面加功能就容易很多。2. 硬件准备与接线检查软件可以后面慢慢调硬件上的坑如果埋下了排查起来非常痛苦。这一节把我在搭建过程中觉得最关键的点都列一下。2.1 堆叠与跳线Audio Adapter 第一道坑Audio Adapter直接插在Teensy 4.1的排针上靠堆叠方式连接不需要额外飞线。但有个细节容易被忽略不同版本的Audio Adapter在LINE IN和MIC的选择上有跳线或者焊桥设置有的版本默认只启用MICLine-in走的是同一路输入通道需要按照丝印提示把通道切换到LINE IN。我第一次接Line-in时发现完全没有信号查了半天才发现是这里。如果你的板子上有类似标注的焊盘或跳线先别急着上电对照丝印看清楚。这个检查比任何代码调试都优先。另外Audio Adapter上的耳机口输出和LINE OUT是两路不同的信号。耳机口后面有耳机功放LINE OUT是codec直出电平不同后面接功放或音箱时别接错位置。2.2 外设接线屏幕、编码器、按钮屏幕我用的是ST7789驱动IC的IPS屏四线SPI接口接线方式如下屏幕引脚Teensy 4.1引脚说明SCK13SPI时钟MOSI11SPI数据CS9片选注意不要用10DC6数据/命令选择RST8复位BL7背光控制编码器EC11接法比较简单A、B两个输出脚分别接两个GPIO公共端接地另外最好在A、B对地各加一个0.1uF电容做硬件消抖。按键引脚接在另一个GPIO上内部上拉就行。这里有个非常重要的提醒Audio Adapter板载SD卡槽占用的SPI片选是CS10如果屏幕也把CS接到10屏幕和SD卡会互相干扰最典型的症状是播放WAV时音乐一卡一卡屏幕刷新时甚至读不到卡。目前这个接法把屏幕CS放到9虽然总线还是共用但至少在片选层面不会冲突。2.3 供电与模拟地处理Teensy这个系统看着简单吃电的地方不少Teensy核心、Audio Adapter的耳机功放、屏幕背光、编码器信号。用电脑USB口供电轻负载下问题不大但音量开大或者屏幕全亮度刷新时会有明显的电源噪声混进音频输出。我实测下来的改进方案是用一个独立的5V/1A以上的充电头供电不要在调试时一边接着电脑USB一边插外部电源。电源选择上优先用线性电源如果实在只有开关电源注意纹波别太大。底噪方面模拟输入线比如Line-in信号线尽量远离屏幕排线、编码器线这些数字信号走线。如果发现有50Hz或100Hz的交流声大概率是地环路问题先检查信号线屏蔽层是不是只在单端接地不要两端都接地。2.4 通电前的检查清单每次改完硬件再上电前我都会过一遍下面这些项基本能避免一半的“奇怪问题”用万用表蜂鸣档测Teensy的3.3V和GND之间的电阻确认没有短路。确认屏幕排线方向插对了ST7789模组如果插反很容易烧背光。SD卡格式化成FAT32里面放一个44.1kHz/16bit/立体声的WAV测试文件。AudioPlaySdWav不支持MP3这点后面还会细说。编码器A、B、按键引脚对地电压应该是3.3V或0V的稳定电平不能是中间值。上电后串口打印SCK有没有输出之类的基础信息确认MCU在运行。这个清单看着琐碎但真到了“屏幕不亮”或“SD卡不识别”的时候基本都是这些基础项出了问题。3. 软件框架用 Audio Library 搭信号流Teensy Audio Library的核心思想是把音频数据变成固定大小的块block每个块包含128个采样点对象之间通过AudioConnection传递这些块。说人话就是一条音频信号流像水管一样从源头接到处理单元再接到最后输出。3.1 对象图设计一条链路讲清楚这一版的信号流设计是这样SD卡播放文件Line-in输入两路信号先进混音器混音后再分三路一路给FFT分析做频谱显示一路进EQEQ输出后到I2S输出。Codec由AudioControlSGTL5000控制。对象声明AudioPlaySdWav playSdWav; AudioInputI2S audioIn; AudioMixer4 mixer; AudioFilterBiquad filterL; AudioFilterBiquad filterR; AudioAnalyzeFFT1024 fft; AudioOutputI2S audioOut; AudioControlSGTL5000 sgtl5000; AudioConnection patchCord1(playSdWav, 0, mixer, 0); AudioConnection patchCord2(playSdWav, 1, mixer, 1); AudioConnection patchCord3(audioIn, 0, mixer, 2); AudioConnection patchCord4(audioIn, 1, mixer, 3); AudioConnection patchCord5(mixer, 0, filterL, 0); AudioConnection patchCord6(mixer, 0, filterR, 0); AudioConnection patchCord7(filterL, 0, audioOut, 0); AudioConnection patchCord8(filterR, 0, audioOut, 1); AudioConnection patchCord9(mixer, 0, fft, 0);这样连完整个系统的逻辑就很清晰了WAV和Line-in各自占用混音器的一路输入混音器输出同时发给EQ和FFT。EQ左右声道各自有一个滤波器实例虽然左右系数一致但AudioFilterBiquad本身是单声道对象不能一个实例同时处理左右两个通道。这里特别说明一下FFT接线的选择。很多人会把FFT接在EQ之后想显示“处理后的频谱”我最终选择从混音器直接旁路出来原因是这样既能同时观察原始播放和Line-in的混合信号又不受EQ增益影响更贴近“输入级频谱”的定位。如果你主打音效可视化改一条连接就行格式完全一样。3.2 AudioMemory 设置不是越大越好Audio Library在启动时需要预留一组内存块给音频数据流动使用。代码里写的是AudioMemory(100)括号里的数字就是预分配的内存块数量。每个audio block承载128个采样点的数据16bit格式下就是256字节再加上库内部的其他开销实际一块约300多字节。我的对象图里同时有播放、混音、滤波、FFT等对象块数给太少会出现一种很典型的故障音乐播放几秒钟就顿一下或者FFT数值不更新串口打印AudioMemoryUsage()看到数值飙到顶。经验值简单的播放链路用30就够这版加了EQ和FFT之后我会直接给到100。如果你的链路更复杂比如加了延迟效果器或卷积混响120也不嫌多。但“越大越好”在这里并不完全对Teensy 4.1的RAM虽然充足但Audio对象的内存和在普通堆区给太多会挤压其他全局变量的空间。最好的方式是先在代码里留一个调试口运行一段时间后观察峰值#include Audio.h void logAudioMemory() { static int maxBlocks 0; int usage AudioMemoryUsageMax(); if (usage maxBlocks) { maxBlocks usage; Serial.printf(AudioMemory peak: %d blocks\n, maxBlocks); } }在loop()里每秒调一次确认峰值没有逼近你设置的块数上限再决定要不要调大或调小。3.3 采样率与 WAV 格式的统一Teensy Audio Library默认工作在44.1kHz采样率Audio Adapter上的SGTL5000 codec内置时钟也是围绕这个频率设计的。这个默认值下所有WAV文件最好统一为44.1kHz/16bit/立体声。如果文件是22.05kHz或者48kHzAudioPlaySdWav播放时可能出现变调或者播放速度异常的现象。原因在于AudioPlaySdWav在开始播放时会尝试根据WAV文件头调整输出采样率但后续其他对象往往还是按默认时钟走两边的节奏就对不上了。我的建议很简单用ffmpeg或者Audacity统一转格式命令行一行搞定ffmpeg -i input.mp3 -ar 44100 -ac 2 -sample_fmt s16 output.wav文件名最好用全大写或区分大小写Teensy的SD库默认字符串比较是区分大小写的Test.wav和test.wav是两回事。AudioPlaySdWav播放时直接给文件名playSdWav.play(test.wav);如果文件在SD卡某个子目录下也可以带路径比如play(music/test.wav)但路径要严格匹配实际文件名。4. 核心功能实现频谱、EQ、混音、交互功能全在代码里这一节我挑最核心的几段展开讲。先说明代码是Arduino环境下基于Teensy Audio Library写的示例尽量精简但关键参数和调用逻辑都保留。4.1 实时频谱显示FFT1024 IPS 屏FFT这部分是整个项目的视觉灵魂。AudioAnalyzeFFT1024对象会把输入的音频流按1024个采样点做一次FFT变换输出512个频率bin。在44.1kHz采样率下每个bin覆盖的频率宽度约等于43Hz44100除以1024。bin 0对应直流分量bin 1对应约43Hzbin n对应n乘以43Hz最高到22.05kHz。代码里的初始化#include Audio.h #include TFT_eSPI.h AudioAnalyzeFFT1024 fft; void setupFFT() { fft.windowFunction(AudioWindowHanning1024); }windowFunction(AudioWindowHanning1024)是加汉宁窗。不加窗的话FFT频谱会出现明显的频谱泄漏低频看起来一坨全是能量高频又都是噪声完全没法看。加了窗之后频率泄漏有效抑制代价是频率分辨率稍微降低但对可视化来说完全够用。拿到FFT数据后不能直接512个点全画在屏幕上。屏幕只有240像素宽要把频率范围映射成适合显示的柱状条而且人耳对低频感知更敏感最好用对数频率轴不要线性映射。我用的简化方案是手动指定一组bin索引范围对应常见频谱分析仪的频段感const uint16_t freqBins[] {2, 3, 4, 5, 7, 9, 12, 16, 21, 28, 36, 47, 63, 84, 112, 149}; const int numBins sizeof(freqBins) / sizeof(freqBins[0]);这16个bin覆盖从约86Hz到约6.4kHz中低频段分辨得更细符合听感习惯。绘制逻辑也不复杂void drawSpectrum() { if (!fft.available()) return; for (int i 0; i numBins; i) { int binIndex freqBins[i]; float value fft.read(binIndex); int barHeight (int)(value * 240.0f); if (barHeight 240) barHeight 240; tft.fillRect(i * 14, 0, 12, 240 - barHeight, TFT_BLACK); tft.fillRect(i * 14, 240 - barHeight, 12, barHeight, barColor(i)); } }