PolyTune 是一款久负盛名的吉他调音器产品,在学习了模拟电路、单片机与信号处理有关知识后,我萌发了“复刻”这一售价上千的产品的想法,争取使用性价比尽可能高的原料达到类似的功能(目前均摊成本价为 ¥100 左右),该项目由此得名。
名字中的 Solo,不仅代表该项目有我一个人“solo”完成,也代表了它的调音能力:一次可以对一根弦调音,精确到 <1Hz。相比于我的致敬,正版 PolyTune 一次可以对吉他的六根弦进行有限的调音(仅可调至标准音),但由于时间限制,加之于问题本身的复杂性(multi-
通电后,屏幕上方的蓝色字显示音高,下方的红色或绿色字显示输入信号相对于该音高偏高(H)还是偏低(L)。若音已调准,则显示绿色 O(OK)。
本次参赛我所上交的部分为 PolyTune 的前级设计图与软件工程部分,代码主要集中在 /User/Core
文件夹里,其中 Src
与 Inc
子目录内分别包含 .c
与 .h
文件。
根目录内的 ioc
文件可用 STM32CubeMX 直接打开,内含具体的硬件配置与引脚定义。如需搭建硬件,请按照此处的引脚定义与外设 PCB 上的丝印进行接线。
软件部分的项目文件为 /MDK-ARM/PolyTune.uvprojx
,可用 Keil μVision IDE 直接打开。
PolyTune 主要分为四部分,分别是:
- 前置放大器(Preamp),
- 使用模—数转换器(ADC)异步采集信号,
- 基于快速傅立叶变换(FFT)的频率检测算法,
- 基于 SPI 协议在彩屏 LCD(型号 MSP2401)上显示结果。
以下详细阐述 preamp、ADC、FFT 与 LCD 驱动部分。
该部分为纯模拟电路,使用了常见的 LM358 运算放大器,对输入的乐器信号(小振幅交流电)放大,并加上 1.65V 的直流偏置。如此处理是受限于 ADC 精度(12-bit)与输入电压范围(0~3.3V)的结果。
输入到运放前信号先通过一个较大的电解电容 C1 去除可能带有的直流分量,随后经过 9V 直流电源与两个电阻(R1、R2)先进行一次约 4.5V 的直流偏置,因为仅用正电源供电时运放无法输出负电压。
运放部分的电路为常见的同相放大电路,引入了负反馈以控制放大倍数。
从运放输出后,被放大的信号仍然带有直流分量,故需经过去耦/滤波电容 C2 去除直流分量。随后,再使用电阻 R5、R6 与恒定的 3.3V 电压源为信号添加正确的 1.65V 直流偏置,输出至 ADC。
3.3V 电压源由原 9V 电源降压而来,使用了 AMS1117-3.3 稳压芯片。C3、C4 为去除电源纹波的陶瓷电容。
实现精确的 1kHz 采样使用了 STM32 最小系统板上的 72MHz 高速晶振。为了获得是其因数的其他频率,可通过 HAL 库使用两个参数控制:
此处设置
采集完毕之后将变量 adcFlag
置为 1,程序检测到后将数据录入。这就是异步编程在单片机中的实际应用,其优点也显而易见:由于采样间隔为 1ms,而每次 FFT 都会增加约 30ms 的耗时。所以,将采样过程与 FFT 如此解耦,实则是采用了“有时间记录就记下来,没时间就先扔掉”的策略,这有助于保持信号点本身的等间隔性。时域上的扭曲会影响频率测定结果,而信号出现间断则不构成问题。
此案例中使用的微控制器为广为人知的 STM32F103C8T6,为 STM32 编程主要有两种方式:使用标准库(与汇编高度挂钩)与使用 HAL 库(Hardware Abstraction Layer,可较为简单地配置硬件,使软件开发更为迅速)。本案例中两者皆有使用,其中显示屏驱动部分主要依赖前者,信号采集与处理部分依赖后者。
FFT(快速傅立叶变换,fast Fourier transform)可以从一段离散的信号中以 arm_cmplx_mag_f32
函数对所有的数据点一次取模。
FFT 的频率精度由如下公式定义:
其中
这个驱动的开发过程极好地体现了为什么标准库与 HAL 库通常不应该混用。两者依赖的头文件之间有大量的重复定义,设计初衷明显只允许在一个项目中二选一。
解决这一问题的方法也很简单:将例程中的标准库定义用 HAL 语法重写。没有人知道初始化函数为什么要向屏幕发送几十个看不出意义的字节,但是如果例程是这样做的,就需要这样重写。如此,LCD 的点亮与驱动没有遇到较大的问题。
在彩屏上显示内容的原理是常见的点阵取模算法,在使用 pygame 开发时也多有使用,不过是在原本的二值图中添加了颜色信息。
因为 ADC 采集的信号电压值必须为正,所以在进行 FFT 时会在 0Hz 处有一个明显的峰(直流分量的频率为零),对频率检测造成较大的影响。解决这个问题的方法极为巧妙:将一帧数据的所有值去平均,再将所有的值减去这个均值,就可以使信号回到原来的“交流”形态,因为正弦信号从宏观上看均值等于零。
另一个难点是对 LCD 控制芯片 ILI9341 的理解与使用。向该芯片传输一个字节的数据先需要进行两次 GPIO 操作(拉高/拉低引脚电平)以“告诉”芯片写入内容的性质(命令还是数据),再将需传的字节传过去,随后需要对操作过的引脚进行复位。
最后,与常规软件工程不同,单片机一般不会报错,在程序出错时只能默默地运行下去,再加上 C 语言的种种特性,导致有的时候一个问题可以在代码里“潜伏”很长时间。
编译器的优化有的时候也会起到“负优化”的作用:一个经常需要在 0/1 之间 flip 的标志变量(即前文的 adcFlag
)因为没有加 volatile
关键字修饰,被编译器直接缓存掉了,导致其本应一直变化的值被清零,使程序无法正常进行。这种问题从逻辑和编码上没有任何问题,所以一旦发生极难排查。
在单片机上编程,其实和编写一个操作系统有很多相似之处:直接配置并操作硬件,没有现成的 printf
(我需要在代码中手动实现它所依赖的 fputc
才能在串口中打印 debug 信息),异步操作常见。
本次开发对我来说是一次难得的学习经历,让我对当年发明高级编程语言的计算机先驱们产生了新的崇高的敬意。这次开发我也算是走了一小段他们曾走过的路,真是令人感怀。
我也将 见你未见的世界
写你未写的诗篇
天边的月 心中的念
你永在我身边
——王菲《如愿》