这篇写给第一次碰 QuecPython、第一次做音频项目的同学。不要求你懂 RTOS、不要求你会 LVGL,只要求你有一块 EC600M-CNLE 开发板、一根能传数据的 USB 线、一个 8Ω 小喇叭。
我把整个过程拆成 6 级台阶,每一级都有一个能直接看见或听见的验收点。任何一级不过,问题就一定在这一级内部——不要跳级,跳级之后的报错都是假的。
一、先看清目标:能做到什么,要花多少钱
成品能力(全部实测):
- 插喇叭听歌,支持 MP3 / WAV / AMR;
- 两个板载按键:短按播放暂停 / 下一曲,长按调音量;
- 240×240 屏幕显示歌名、进度、频谱;
- 断点续播(暂停后接着听,不用从头);
- 可选:外挂 NOR Flash 扩容曲库、GT911 触摸、ESP32 蓝牙桥、阿里云远程控制。
硬件清单:
| # | 器件 | 型号 | 价格 | 必需 |
|---|---|---|---|---|
| 1 | 开发板 | EC600M-CNLE EVB | 已有 | 是 |
| 2 | 喇叭 | 8Ω 2W,带 2pin 线 | ¥5 | 是(板载 SPK 座直插) |
| 3 | USB 线 | Micro-USB 数据线(不是充电线) | ¥5 | 是 |
| 4 | SPI NOR | W25Q64(8MB)模块 | ¥6 | 否(曲库扩容) |
| 5 | 触摸屏 | ST7789 240×240 + GT911 | ¥40 | 否 |
| 6 | 蓝牙桥 | ESP32-S3 N16R8 | ¥25 | 否 |
为什么选 EC600M:它把 4G Cat.1、音频 codec、LCD 专用接口、USB 串口全塞进一块板,板载还焊好了屏、功放、两个按键。你要做的只有“插喇叭、写代码”两件事——这对第一次做音频项目的人极其重要,因为少一个环节就少一类玄学问题。
整条路拆成 6 级台阶:
下面按这 6 级一级一级走。
二、开工前必须接受的 4 条 QuecPython 规矩
这四条不是“最佳实践”,是这个平台的地形。不接受就会一直踩坑。
规矩 1:/usr 是平铺的,没有真正的包。
所有 .py 都放 /usr/ 下,import 靠 sys.path。所以要么写 from usr.modules.keys import Key,要么写一层 fallback:
try:
from modules.keys import Key # QPYcom 里直接跑
except ImportError:
from usr.modules.keys import Key # 上电自启时
规矩 2:调试入口叫 _main.py,量产才叫 main.py。
固件上电只自动执行 /usr/main.py。调试期用下划线开头,避免每次复位都抢你的 REPL。量产时加两行垫片:
# /usr/main.py —— 全部就这两行
import example
example.exec('/usr/_main.py')
规矩 3:REPL 是独占的。
脚本在跑的时候,REPL 不读你的输入。想停下来敲命令,先按 Ctrl-C。
规矩 4:函数体内不要 import gc。
在 MicroPython 里,函数体内的 import gc 会让 gc 变成局部变量,而 Python 在编译期就决定了它是局部的——于是你在 import 之前写的任何 gc.mem_free() 都会抛 UnboundLocalError。统一放文件顶部。
三、Step 0:装环境,让板子先说话
① 装驱动。 插上 USB,设备管理器应出现 Quectel USB AT Port / DIAG Port / NMEA Port 三个口。少一个就是驱动没装好,后面全白搭。
② 装 QPYcom。 移远官方工具,三件事都用它:烧固件、传文件、开 REPL(交互页)。
③ 烧固件。 这一步有个必须确认的点:固件要带 audio 和 LVGL。烧完在 REPL 里敲:
import audio; print(audio)
import lvgl; print(lvgl)
两条都打印出模块对象才算过。标准固件可能不带 LVGL,那就换带 LVGL 的版本——不要硬写,写到一半发现 lvgl 不存在是最难受的。
④ 传文件。 QPYcom 的“文件”页直接拖进 /usr/。或者用脚本批量传(我项目里的 tools/ser_upload.py,传完逐文件 md5 校验,避免“传了一半”这种隐性故障)。
验收点:REPL 里 print("hello") 有回显,uos.listdir("/usr") 能看到你传的文件。
四、Step 1:让它出声(最重要的一步)
4.1 为什么要过三道关
EC600M 的音频通路有三道独立的闸,任意一道没开,现象都是“不响”,但原因完全不同:
| 关卡 | 正确做法 | 没过的现象 |
|---|---|---|
| 设备号 | audio.Audio(0) |
初始化抛错,或“正常返回但全程静音” |
| 功放使能 | set_pa(Pin.GPIO9, 2) |
解码回调全跑、PCM 有数据,喇叭无声 |
| 音量档位 | setVolume(9) |
只有轻微底噪,听不见人声 |
device 只能是 0(听筒通道),写 1/2 在部分固件直接抛错。功放是板载 NS4160,PA_CTRL 接 GPIO9——play() 和 TTS 由固件自动发脉冲,但自己喂 PCM/Opus 流时必须手动拉两个上升沿,否则芯片内部状态机不认。
4.2 最小可跑脚本
先准备一个测试音。别一上来传整张专辑:/usr 分区只有 576KB,一首 4MB 的 MP3 根本放不下。
- 8kHz / 16bit / 单声道 / 5 秒 WAV ≈ 78KB;
- 或 32kbps MP3 / 10 秒 ≈ 39KB。
用 PC 上的 ffmpeg 一条命令就能转:
ffmpeg -i song.mp3 -t 10 -ar 8000 -ac 1 -b:a 32k test.mp3
传到 /usr/music/test.mp3,然后跑这 20 行:
import audio
from machine import Pin
from utime import sleep_ms
aud = audio.Audio(0) # ① 只有 0 可用
aud.setVolume(9) # ② 音量:0 不是“小”,是彻底静音
try:
ret = aud.set_pa(9) # ③ 功放:部分固件吃 int
except Exception:
ret = aud.set_pa(Pin.GPIO9, 2) # 标准固件要求 (pin, pulse=2)
print("set_pa ret =", ret) # 1 = 成功
ret = aud.play(2, 1, "U:/music/test.mp3") # priority=2, breakin=1
print("play ret =", ret) # 0=成功 1=入队 -1=失败
sleep_ms(3000)
aud.stop()
验收点:喇叭响 3 秒。不响就按下表对号入座,不要改代码。
4.3 三个失败现象,三条不同的路
| 现象 | 判据 | 修法 |
|---|---|---|
| 完全安静,连底噪都没有 | 万用表量喇叭两端:无电压 | PA 没使能 → 查 set_pa 返回值 |
| 有极轻底噪,听不清 | 打印当前音量 | setVolume(9)(PCM 默认可能是 0) |
| 报错 / 静音但初始化成功 | 打印 device |
改回 audio.Audio(0) |
如果三步都对还是不响,检查文件本身:audio.play 只支持 mp3 / wav / amr,FLAC、M4A、OGG 一律不行(改后缀也没用,解码器看文件头)。
五、Step 2:按键——为什么“双边沿”会让板子重启
按键看起来最简单,实际是新手第一个大坑。EC600 系列的普通 GPIO 不支持双边沿中断,写了 IRQ_RISING_FALLING 会在下一次按键时 HardFault,现象是串口“啪”一下断开、板子重启。你会以为是 USB 线松了。
正确做法:中断只监听按下沿(单边沿),松开靠线程轮询判断。
import _thread
from machine import Pin, ExtInt
from utime import sleep_ms, ticks_ms, ticks_diff
from queue import Queue
_q = Queue(16)
ext = None
def on_edge(args):
"""中断上下文:只做 disable + 入队,全程 < 50us"""
ext.disable()
_q.put(1)
def worker():
while True:
_q.get()
sleep_ms(20) # 消抖 20ms
gpio = Pin(Pin.GPIO13, Pin.IN, Pin.PULL_PU, 1)
if gpio.read() == 0: # 确认真的按下了
deadline = ticks_ms() + 3000 # 最长等 3s,防卡死
while ticks_diff(deadline, ticks_ms()) > 0:
sleep_ms(10)
if gpio.read() != 0: # 松手 = 一次短按
print("PRESSED")
break
ext.enable() # ★ 必须重新使能
ext = ExtInt(Pin.GPIO13, ExtInt.IRQ_FALLING, ExtInt.PULL_PU, on_edge,
filter_time=20)
_thread.start_new_thread(worker, ())
三个细节都是踩出来的:
filter_time=20是硬件滤波参数,能挡掉大部分触点抖动;ext.enable()必须放在轮询之后,忘了它就“只能按一次”;- 中断回调里绝不能
sleep、不能play()、不能开线程——中断上下文里做耗时操作,轻则丢事件,重则直接崩。
板载按键位置:K1 = GPIO13(丝印 S4),K2 = GPIO12(S5)。按下是低电平(内部上拉),所以不用外接电阻。
六、Step 3:曲库——扫目录、认格式、拒伪文件
import uos
EXTS = (".mp3", ".wav", ".amr")
def header_ok(path):
"""看文件头,拒绝“改了后缀的 M4A”"""
try:
with open(path, "rb") as f:
h = f.read(12)
except OSError:
return False
if len(h) < 12:
return False
if h[:3] == b"ID3" or (h[0] == 0xFF and (h[1] & 0xE0) == 0xE0):
return True # MP3(带 ID3 或裸帧同步字)
if h[:4] == b"RIFF" and h[8:12] == b"WAVE":
return True # WAV
if h[:6] == b"#!AMR\n":
return True # AMR
return False
def scan():
for d in ("U:/music", "/usr/music"):
try:
names = uos.listdir(d)
except OSError:
continue
kept = []
for n in names:
low = n.lower()
ok = False
for ext in EXTS: # ★ 不能写 low.endswith(EXTS)
if low.endswith(ext):
ok = True
break
if ok and header_ok(d + "/" + n):
kept.append(n)
elif ok:
print("[SKIP] 文件头不认:", n)
kept.sort()
return d, kept
return None, []
两个 MicroPython 专属坑:
str.endswith()不支持 tuple。CPython 里name.endswith((".mp3", ".wav"))合法,MicroPython 直接抛TypeError: can't convert 'tuple' object to str implicitly。必须手写循环。str没有isalnum()这类方法(同理)。写业务代码前先在 REPL 里试一下,比写完再查快得多。
魔数校验很值:用户把 .m4a 改名成 .mp3 是常事,不校验就会在播放时抛异常,而异常发生在解码线程里,很难定位。
七、Step 4:屏幕——板载 LCD 不是“自己接 SPI”
代码怎么放、为什么分三层,先看这张图:
两个容易搞错的前提:
- 屏是板载 ST7789V,走 LCD 专用接口(
machine.LCD),不是你买块 SPI 屏飞六根线。所以代码里只有lcd_init,没有 CS/DC/RST 那些引脚。 - 文件必须 /usr 平铺,
main.py只做入口,业务分到modules/下,靠sys.path找。
LCD 初始化就一行,但参数很讲究:
from machine import LCD
lcd = LCD()
lcd.lcd_init(init_list, 240, 240, 13000, 1, 4, 0,
invalid_list, display_on_list, display_off_list, None)
init_list 是一串三元组:(类型, 长度, 值),类型 0 = 写寄存器、1 = 写数据、2 = 延时毫秒。这里有一个百分之百会踩的坑:网上抄的 ST7789 初始化序列经常少了尾巴段(0x36 / 0x2A / 0x2B / 0x2C),烧进去就是整屏发白或只显示一条。以官方 drivers 仓库那份为准,别抄博客。
LVGL 侧只有四条规矩,记牢就不会卡:
import lvgl as lv
lv.init()
_buf = bytearray(240 * 40 * lv.color_t.__SIZE__) # ① 整块 buf,别用 1 行
_draw = lv.disp_draw_buf_t()
_draw.init(_buf, None, len(_buf) // lv.color_t.__SIZE__)
_drv = lv.disp_drv_t()
_drv.init()
_drv.draw_buf = _draw
_drv.flush_cb = my_flush # ② 回调里 lcd_write + flush_ready
_drv.hor_res = 240
_drv.ver_res = 240
_drv.register()
while True:
try:
lv.timer_handler() # ③ 20~50ms 泵一次
except AttributeError:
lv.task_handler()
sleep_ms(20)
- display buffer 要整块,一行一行刷会撕裂;
- flush 回调必须调
flush_ready(),否则 LVGL 永远等不到“刷完了”,界面卡死; - 20~50ms 泵一次
timer_handler,太慢动画卡、太快白耗 CPU; - 对象要持引用。
lv.label(scr)之后如果局部变量被 GC 回收,界面会变成空白——把控件挂到self.xxx上。
八、Step 5:串成整机——一个队列 + 一个状态机
到这里你会发现一个问题:按键在中断线程、音频回调在固件线程、UI 在主循环——三个线程同时改状态,必然打架。
解决办法只有一个:所有事件先进一个队列,只有一个线程能改状态。
from queue import Queue
_q = Queue(64)
PLAYING, PAUSED, IDLE = 1, 2, 0
class Player(object):
def __init__(self, lib, aud):
self.lib = lib
self.aud = aud
self.idx = 0
self.state = IDLE
def toggle(self):
if self.state == PLAYING:
self.state = PAUSED # ★ 先改状态,再 stop
self.aud.stop() # stop 会补发伪 evt=7
else:
self.state = PLAYING
self.aud.play(2, 1, self.lib[self.idx])
def next(self):
self.idx = (self.idx + 1) % len(self.lib)
if self.state == PLAYING:
self.aud.play(2, 1, self.lib[self.idx])
def event_thread(p):
while True:
evt = _q.get()
if evt == "TOGGLE":
p.toggle()
elif evt == "NEXT":
p.next()
为什么“先改状态再 stop”这么重要?因为 Audio.stop() 会让固件补发一个 evt=7(播放完成)。如果你的状态还是 PLAYING,这个假完成就会被当成“自然播完”,自动切下一首——表现就是“我明明按了暂停,过一会儿它自己又播了”。
这一条我在项目里花了整整两天才定位到,日志里那行 evt=7 (suppressed: fake finish after manual stop) 就是为它加的。
主循环长这样,很朴素:
while True:
try:
ui.tick(p.state)
p.tick_audio() # 处理音频事件、刷新进度
sleep_ms(20)
except KeyboardInterrupt:
break
except Exception as e:
print("[MAIN] loop err:", e) # 主循环绝不能裸奔
except Exception 兜底不是偷懒,是保命:主循环一崩,整机就死了;打条日志继续跑,至少还能响应按键。
九、Step 6:断点续播——Audio 没有 pause
QuecPython 的 Audio 没有暂停接口,stop() 之后只能从头播。想要“接着听”,只有一条路:playStream + 字节偏移。
def resume(path, seconds, byte_rate, data_offset, file_size):
off = data_offset + int(seconds * byte_rate) # 秒 → 字节
return min(off, max(0, file_size - 4096)) # ★ 钳位,保留尾部 4KB
三处必须钳位:
- 文件被替换成更小的 → 裸 offset 越界,
f.seek()直接抛OSError; - 落在文件末尾 → 解码器读不到完整帧,既没声音也等不到结束事件;
- 码率换算有舍入 → 落在帧中间,出“噼啪”噪声。
playStream 的格式常量:2 = WAV-PCM、3 = MP3、4 = AMR-NB。喂流时不要一次 read() 整个文件(内存扛不住),按 10KB 分块喂、每次间隔 15ms。
十、接线附录:三处可选外设
主流程只需要“插喇叭”——板载按键和屏都已经焊好。想扩展再看这三块。
① SPI NOR(曲库扩容):EC600M 不支持 SD 卡(VfsFat 只给 ECx00N),扩容量只能挂 SPI NOR + littleFS。
| 功能 | 排针 | GPIO |
|---|---|---|
| CLK | J6.12 | GPIO1 |
| MOSI | J5.4 | GPIO30 |
| MISO | J5.3 | GPIO4 |
| CS | J5.5 | GPIO2 |
| VCC / GND | J6.2 / J6.17 | — |
代码里 uos.VfsLfs1(32,32,32,"ext_fs",spi_port=1,spi_clk=0) 会自动占用 SPI1,GPIO 编号只用于接线。
② GT911 触摸:硬件 I2C1 的 SCL 是 GPIO15,而 GPIO15 就是 LCD_TE——占用它会干扰刷屏。所以必须用软件 I2C 走两路空闲脚:
| 功能 | 排针 | GPIO |
|---|---|---|
| SCL | J5.10 | GPIO16 |
| SDA | J5.12 | GPIO10 |
| INT | J6.9 | GPIO6 |
| RST | J6.8 | GPIO5 |
③ ESP32 蓝牙桥:EC600M 的内置蓝牙只有 BLE,没有 A2DP,推不了蓝牙耳机。要蓝牙只能外挂发射模块,而且音频走模拟线、UART 只走控制——115200 的 UART 约 11KB/s,扛不住 MP3 流。接线:TX(GPIO32/J5.8) → 模块 RX,RX(GPIO31/J5.9) ← 模块 TX。
十一、七个真实坑(每个都花了半天以上)
| # | 现象 | 根因 | 修法 |
|---|---|---|---|
| 1 | 串口突然断开、板子重启 | 普通 GPIO 开了双边沿中断 → HardFault | 只开单边沿 + 线程轮询释放 |
| 2 | 暂停后自己又播了 / 自动跳歌 | stop() 补发伪 evt=7 被当成自然播完 |
stop 前先置 PAUSED + 900ms 抑制窗 |
| 3 | 解码正常但喇叭不响 | PA 没使能(PCM 流不会自动脉冲) | 手动 2 个上升沿 + setVolume(9) |
| 4 | “传了歌但列表是空的” | /usr 只有 576KB,传一半失败 |
先传 10 秒测试音,查 md5 |
| 5 | 换首歌就播不出来 | .m4a 改名成 .mp3,解码器读不了 |
文件头魔数校验 |
| 6 | 刷屏整屏发白 | ST7789 init 序列漏了尾巴段 | 以官方 drivers 版为准 |
| 7 | OTA 重启后板子不动 | OTA 包里没有 main.py 垫片 |
打包时强制包含 main.py |
十二、给你的一条最短路径
如果你今天就想听到声音,按这个顺序,别跳:
- 30 分钟:装驱动 + QPYcom + 烧对固件,REPL 打印
hello; - 30 分钟:插喇叭,跑 4.2 那 20 行,听到测试音;
- 1 小时:跑通按键(先只打印 PRESSED,不接业务);
- 1 小时:跑通曲库扫描(先只打印文件名);
- 2 小时:接上 LVGL,屏幕上出一个“歌名 + 进度条”;
- 半天:把队列和状态机串起来,整机能用;
- 可选:续播、触摸、蓝牙桥、云控。
最后一张图是我自己贴在桌上的排查顺序:
先量电平,再看日志,最后才怀疑代码。 嵌入式里 80% 的“玄学问题”,都是“没量电平就开始改代码”。没有数据就改代码,等于蒙着眼睛改——改十次也不知道哪次对了。
如果这篇能帮你少走一个坑,那它就没白写。有问题评论区见,我能复现的都会回。





