从一块空板到能听歌:EC600M 便携播放器的完整接线与代码教程

这篇写给第一次碰 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, ())

三个细节都是踩出来的:

  1. filter_time=20 是硬件滤波参数,能挡掉大部分触点抖动;
  2. ext.enable() 必须放在轮询之后,忘了它就“只能按一次”;
  3. 中断回调里绝不能 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)
  1. display buffer 要整块,一行一行刷会撕裂;
  2. flush 回调必须调 flush_ready(),否则 LVGL 永远等不到“刷完了”,界面卡死;
  3. 20~50ms 泵一次 timer_handler,太慢动画卡、太快白耗 CPU;
  4. 对象要持引用。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

十二、给你的一条最短路径

如果你今天就想听到声音,按这个顺序,别跳:

  1. 30 分钟:装驱动 + QPYcom + 烧对固件,REPL 打印 hello;
  2. 30 分钟:插喇叭,跑 4.2 那 20 行,听到测试音;
  3. 1 小时:跑通按键(先只打印 PRESSED,不接业务);
  4. 1 小时:跑通曲库扫描(先只打印文件名);
  5. 2 小时:接上 LVGL,屏幕上出一个“歌名 + 进度条”;
  6. 半天:把队列和状态机串起来,整机能用;
  7. 可选:续播、触摸、蓝牙桥、云控。

最后一张图是我自己贴在桌上的排查顺序:

先量电平,再看日志,最后才怀疑代码。 嵌入式里 80% 的“玄学问题”,都是“没量电平就开始改代码”。没有数据就改代码,等于蒙着眼睛改——改十次也不知道哪次对了。

如果这篇能帮你少走一个坑,那它就没白写。有问题评论区见,我能复现的都会回。