SoundMat (12) 软件设计与开发

本文档描述 SoundMat 上位机的软件层设计与开发方案。硬件、电子系统、传感器原理、ESP32 固件等部分见对应的设计文档;本文档假设硬件已经通过串口提供传感数据帧,专注于树莓派上运行的 Python 程序与 SuperCollider 音频引擎的设计。


1. 运行平台

SoundMat 上位机运行在 Raspberry Pi 3B V1.2 上,使用 Raspberry Pi OS Lite(无桌面环境的精简版本)。选择这个组合的考量是:

Raspberry Pi OS Lite 比标准版省下大量后台资源,留给音频处理。运行时不需要图形界面——所有人机交互通过 SSH 或 Web 控制台完成。

开发机:macOS 上同样可运行(uv run soundmat),scsynth 路径由 SOUNDMAT_SCSYNTH 覆盖;串口在 Mac 上为 /dev/cu.usbserial-*。部署目标仍是 Pi + USB DAC。


2. 系统总览

整个 SoundMat 软件系统的数据流如下:

                                                    +-----------+
   +-------+   serial    +-----------+    OSC       |  scsynth  |
   | ESP32 |  ---------> |    Pi     |  ----------> |    (SC)   | 
   |       |   S 帧       |  Python   |             |           |
   |       |  <--------- |           |        +-----+-----------+
   +-------+   serial    +-----------+        +-> USB DAC --> 音响
       ↓        L 帧
   LED 灯带

具体步骤:

  1. 传感数据接收。树莓派通过 USB 串口持续接收 ESP32 发送的 S 帧(每帧 256 个 12-bit ADC 值,约 20-30 fps)。

  2. 数据解析。Python 程序将原始 ADC 矩阵解析为"哪些 (ring, slice) 位置上有物体"的状态快照,并进一步识别出"放下 / 拿起"等交互事件。

  3. 音乐逻辑。根据传感器状态和交互事件,音乐逻辑层决定要触发什么音、调整什么参数。这一层不直接发声,只产生抽象的音乐事件。

  4. 音频合成。音乐事件被翻译成 OSC 消息发送给 SuperCollider 的 scsynth 进程,scsynth 实例化 SynthDef、合成音频或回放样本,通过 USB DAC 输出到外部音响。

  5. LED 反向链路。Python 程序同时根据当前状态计算 LED 灯带的颜色 buffer,编码成 L 帧通过同一条串口发回 ESP32,由 ESP32 驱动 WS2812B 灯带。


3. 音频引擎:SuperCollider

SoundMat 的音频引擎选用 SuperCollider(以下简称 SC)。SC 是一个开源的实时音频合成与算法作曲平台,最早由 James McCartney 在 1996 年发布。它由两个独立程序组成:

选择 SC 的理由:


4. 音频引擎软件架构

架构设计思路

逻辑是 Python 的强项,音频是 SuperCollider 的强项。能放在 Python 的逻辑就不要放在 SuperCollider。但这不意味着 Python 要全方位地操控 SuperCollider——而是要在 SuperCollider 端做好合适的抽象,暴露出合适的接口,让 Python 拿到的是"声音积木"而不是底层音频细节。

Python 与 SC 的对接方案对比

Python 与 SC 通信有几种主流方案:

方案说明评价
手写 OSCPython 用 python-osc 直接发 /s_new、/n_set 等消息灵活但啰嗦,所有节点 ID、参数管理都要自己来
subprocess + sclangPython 启动 sclang 子进程,通过文本接口下指令间接、慢、容错难
Python supercollider 库Python 直接通过库 API 控制 scsynth,库内部封装 OSC抽象层级合适,可读性好

我们选用第三种:python 的 supercollider 库。它在 Python 层提供了对 scsynth 节点、SynthDef、Buffer 等概念的封装,既比手写 OSC 高级,又比起 sclang 子进程方案更直接。

sclang 与 scsynth 的关系

SC 实际上由两个独立程序组成:

正常使用 SC 时,sclang 把音乐逻辑翻译成 OSC 消息发给 scsynth,scsynth 收到消息后实例化 Synth 节点合成声音。两者之间通过 OSC 通信——这也是为什么我们可以绕开 sclang 直接用 Python 控制 scsynth。

我们的实际架构

[ 过去:纯 SuperCollider ]
音乐逻辑 (sclang) ──> 实例化 SC 自身的 Synth 类 ──> 指挥 scsynth 节点
                                ▼
                      [sclang 定义的 SynthDef]


[ 现在:Python + supercollider 库 + SC ]
音乐逻辑 (python) ──> 实例化 python 库的 Synth 类 ──> 指挥 scsynth 节点
                                ▼                          ▼
                    [Python 库中的同名傀儡类]    [提前编译的 SynthDef]
                                                           ▼
                                              [sclang 定义的 SynthDef]

简单说:

这样的好处:所有音乐逻辑都是 Python,可以用 Python 完整的生态(数据结构、yaml/toml 加载、HTTP 服务、Web 框架);所有发声都是 scsynth,性能稳定。两边的边界清晰——Python 写"什么时候响什么",SC 写"响起来是什么样的"。

我们只需要将声音素材写成 SynthDef、编译成 .scsyndef 文件,之后使用 Python 写逻辑、控制音乐即可。


5. 详细软件架构

5.1 整体设计思路

SoundMat 支持两种播放模式:

两个模式表面看起来差异很大,但它们共用同一套硬件(Pi、ESP32、USB DAC、LED 灯带)和同一个 SC 服务器。架构设计要在"两个模式各自做最自然的事"和"共享资源不重复"之间找平衡。

实际上两个模式真正共享的只有硬件 I/O 和 SC 服务器,它们的音乐逻辑没有共同点。所以我们不强行抽象出统一的"音乐引擎",而是把两个模式当成两个独立的应用,跑在同一套底层服务上。共享部分集中在 core/ 模块里,模式自己的逻辑分别在 ambient/ 和 jam/ 里独立发展。

主程序根据 manifest 文件选择启动哪个模式。运行时只有一个模式 active,模式切换通过 Web 控制台触发,无需重启程序。

5.2 Ambient 模式核心思路

Ambient 模式用于公共艺术装置:用户在圆形垫面上放石头,系统实时生成氛围音乐。主循环约 20 Hz。

核心数据流:

sensor → tracker → state → mapper → voices → engine → scsynth

一句话:sensor 读出垫上有哪些石头 → state 算出本帧变化 → mapper 决定该响什么 → voices 做帧间 diff 并执行 → engine 通过 OSC 驱动 scsynth。

混合交互模型。Ambient 模式中,声音分两类,采用不同处理方式:

逻辑与音频严格分离:

样本按 Ring 组织(低音、中频、竹林、天气、旋律、事件等),通过 manifest(如 kyoto.toml)声明式装配。SynthDef 离线编译成 .scsyndef,运行时只加载编译产物。FX 链固定:sources → reverb → master。

模块为扁平文件(非子包目录):tracker.py、state.py、mapping.py、voices.py、buffers.py、led.py。

5.3 Jam 模式核心思路

Jam 模式的核心原则一句话:把"音乐内容(What)“和"音乐执行(How)“彻底分离。所有可替换的东西都做成声明式配置数据,代码只负责通用调度和解释。配置改了换首曲子,代码一行不动。

整个 Jam 模式分五层:

配置层 → 音乐理论层 → 调度层 → 事件层 → 桥层 → SC

关键可调点:

走一遍数据流(以一个具体音为例)。假设当前时刻:BPM=120, key=C,当前和弦是 2m9,扫描线正走到 slice 4,R4 slice 4 上有一颗石头。这一音的"身份"在每层是这样的:

Layer它长什么样
配置"2"(字符串,melody 表里的一格)
调度slice=4(不知道音是什么)
事件NoteEvent(ring=4, degree="2", ...)
桥parsed=(2, 0, 0) → midi=62 → freq=293.66
SCfreq=293.66 Hz(不知道它叫"2”、“D”,也不知道 key)

整条路径上**“具体音高"只在桥层那一瞬间存在**。这就是为什么改一行 key: "G" 之后,整个配置和事件流都不用动——唯一变化的是桥层调用 Tonality 时拿到的映射表。


5.4 目录结构

soundmat/                                ─── 仓库根 (src/soundmat/) ───
├── README.md
├── SETUP.md                             # 上机部署与校准说明
├── pyproject.toml
├── uv.lock
│
├── manifest/
│   ├── ambient/
│   │   └── kyoto.toml
│   └── jam/
│       ├── lofi_1.toml                  # 默认启动 manifest
│       ├── lofi_1b.toml
│       └── data/
│           ├── progressions/
│           ├── melodies/
│           ├── drums/
│           ├── rings/
│           │   └── lofi.yaml
│           └── lofi_mapping.yaml
│
├── soundmat/                            ─── Python 包 ───
│   ├── __init__.py
│   ├── __main__.py                      # CLI 入口;启动 scsynth / 串口 / Web
│   ├── config.py                        # 全局常量(校准默认、总线号、Jam 行为)
│   ├── app.py                           # ModeManager + manifest 加载
│   │
│   ├── core/
│   │   ├── sc_server.py                 # scsynth 子进程 + .scsyndef 加载
│   │   ├── osc.py                       # OSC 客户端(节点 ID 自 100000 起)
│   │   ├── esp32_serial.py              # 串口 auto 识别、打开、boot 等待
│   │   ├── services.py                  # SharedServices
│   │   ├── sensor/
│   │   │   ├── reader.py                # SensorReader 抽象 + 订阅
│   │   │   ├── serial_reader.py         # ESP32 S 帧读取
│   │   │   ├── mock_reader.py           # mock / .npz 回放 / Web 垫面注入
│   │   │   ├── frame.py                 # S 帧解析与校验
│   │   │   ├── map.py                   # Wire → 逻辑 (ring, sector) S→L 映射
│   │   │   └── normalize.py             # wire slice 列镜像(部署可调)
│   │   └── led/
│   │       ├── writer.py                # L 帧编码 + 60Hz 发送
│   │       └── offset.py                # 物理 LED 旋转
│   │
│   ├── web/
│   │   ├── server.py                    # FastAPI:HTTP + WebSocket 全在此
│   │   └── static/
│   │       ├── index.html               # Control:模式切换、音量、BPM
│   │       ├── mat.html                 # Mat:热力图、虚拟垫、Calibration
│   │       ├── debug.html
│   │       └── soundmat.css
│   │
│   ├── modes/
│   │   └── base.py                      # ModeApp Protocol
│   │
│   ├── ambient/
│   │   ├── app.py
│   │   ├── tracker.py
│   │   ├── state.py
│   │   ├── mapping.py
│   │   ├── voices.py
│   │   ├── buffers.py
│   │   └── led.py
│   │
│   └── jam/
│       ├── app.py                       # ~200Hz 主循环
│       ├── types.py
│       ├── config_loader.py
│       ├── ring_config.py
│       ├── theory/                      # degree, tonality, tempo
│       ├── scheduler/                   # transport, scanline, song_position, timing
│       ├── event/                       # sensor_state, harmony/drum/event_engine
│       ├── bridge/                      # synth_bridge, master_fx
│       └── led/                         # renderer, layers(调色板/几何/包络)
│
├── sc/
│   ├── synthdefs/
│   │   ├── core/                        # master, reverb, players
│   │   ├── ambient/                     # diapason, marimba
│   │   └── jam/
│   │       ├── 00_common.scd            # 共享 ugen 辅助
│   │       ├── master.scd               # jam_master(三总线 + Lo-Fi)
│   │       ├── sub_bass.scd, pluck_bass.scd, pad_pluck.scd, rhodes.scd, marimba.scd
│   │       ├── chord_pad.scd
│   │       └── drums/                   # kick, snare, hihat
│   ├── assets/samples/
│   ├── compiled/                        # *.scsyndef
│   └── compile.scd
│
├── scripts/
│   ├── compile_synths.py
│   └── replay_test.py
│
└── tests/                               # pytest(含 sensor_map、led、integration 等)

5.5 几个关键文件的解释

jam/types.py — 契约文件:NoteEvent、ChordEvent、ParamEvent、SliceTick、SongPosition 等。NoteEvent 含 source_slice(LED 命中闪)、when(预留);和声走 ChordEvent 而非 NoteEvent。

core/sensor/map.py + normalize.py — S 帧为 wire 坐标 (ring, slice);经可选列镜像 + SECTOR_OFFSET 旋转得到逻辑 (ring, sector)(恒 32 sector)。内圈 R0/R1 在 pre-offset 层由 16 个有效 wire slice 映射。Jam SensorState 与 Ambient Tracker 均在此逻辑坐标上阈值化。

core/sensor/reader.py vs serial_reader.py / mock_reader.py — 抽象接口 + 真串口 / mock 实现。_emit() 时统一做 wire 镜像。模式 app 只消费 latest() / subscribe()。

core/esp32_serial.py — pick_serial_port("auto")、921600 打开、首帧 S 帧 boot 等待(8s)。

config.py — 部署默认:SENSOR_THRESHOLD=500、CONTROL_SUM_MIN=250、CONTROL_MAX=1000(Lo-Fi 映射上限)、SECTOR_OFFSET=4、LED_OFFSET=101、WIRE_SLICE_MIRROR=True;Jam 放石 preview 音量 JAM_IDLE_PLACE_PREVIEW_VEL=0.5、JAM_LOOP_PLACE_PREVIEW_VEL=0.25(0=关);串口断开 SERIAL_LOST_EXIT_SEC=3 后进程退出。

core/services.py — SharedServices 打包 sc / sensor / leds / osc,以及 serial_port、serial_ready。

modes/base.py — ModeApp Protocol(start / stop / status / set_param)。

5.6 Manifest 的引用关系

Jam 模式的 manifest 文件本身是个"装配清单”,指向各个数据文件:

# manifest/jam/lofi_1.toml
mode = "jam"
name = "Lo-Fi Loop · Progression 1"
description = "京都夜雨,2m9 → 513 → 1maj9 → 69"

[music]
bpm = 120
key = "C"
scale = "major"

[refs]
progression = "1"      # → data/progressions/1.yaml
melody_pack = "1"      # → data/melodies/1/
drums = "lofi_loop"    # → data/drums/lofi_loop.yaml
rings = "lofi"         # → data/rings/lofi.yaml
lofi_mapping = "default"

这样的好处是灵活性:同一套 melody 数据可以被进行 1 和 1B 共用(如果作曲家选择),也可以分别独立。Ambient 模式的 manifest 同理,声明该 preset 用哪些样本组、ring 怎么映射等。


6. Web 控制台

6.1 设计动机

SoundMat 上没有物理按钮(为了保持装置美学纯粹)。但开发、调试、Demo 阶段需要某种方式控制系统:切换模式、启停、调参数。Web 控制台是最优雅的方案——树莓派开一个轻量 HTTP 服务,手机扫码访问,装置外观零改动。

更重要的是,Web 控制台不只是为模式切换而做的,它是开发整个项目的基础设施:

6.2 控制台页面

路径页面作用
/Control (index.html)模式切换、音量、Jam BPM、Place preview 滑条、Developer Restart/Stop
/matMat (mat.html)实时热力图、Calibration、mock 虚拟垫与 LED 镜像
/heatmap、/pad同 Mat别名
/debugDebug调试

Jam 启停:播放由 R0/R1 控制石(压力之和 ≥ CONTROL_SUM_MIN)驱动,不是 Control 页的 Transport。Control 页 Restart/Stop 仅重启/停止当前模式线程(Developer 区说明)。

6.3 HTTP / WebSocket API

GET  /                          # Control 主页
GET  /mat                       # Mat 页(热力图 + 校准)
GET  /debug

GET  /api/status                # 模式状态 + 校准参数 + app 子状态
GET  /api/manifests             # 可用 manifest 列表
POST /api/mode                  # {"manifest": "…/lofi_1.toml"}
POST /api/transport             # {"action": "start"|"stop"} 模式 lifecycle
POST /api/params                # 全局或模式参数(见下)

POST /api/mock/cells            # mock 模式:注入 {(ring,sector),…}

WS   /api/sensor_stream         # 传感矩阵 ~30fps
WS   /api/event_stream          # 状态快照 ~10Hz(调试)
WS   /api/mock/view             # mock:LED buffer + status ~40fps

POST /api/params 全局键(ModeManager,重启恢复默认):sector_offset、led_offset、sensor_threshold、control_min、control_max、control_sum_min、wire_slice_mirror、jam_loop_place_preview_vel(0–1,0=关)。模式级键如 bpm、master_volume 转发给当前 ModeApp.set_param。

6.4 实现要点


7. 启动流程

$ uv run soundmat [manifest/jam/lofi_1.toml]   # 默认 lofi_1,非 kyoto
   ↓
__main__.py:
  1. 解析 CLI(--mock、--port、--no-serial、--no-web、--no-sc、--list-ports)
  2. 启动 scsynth(-u 57110 -i 0 -o 2 -S 44100);失败最多重试 5 次
  3. load_synthdefs(sc/compiled)
  4. 打开 ESP32 串口(auto),等待首帧 S(8s);失败则转 mock
  5. SensorReader + LEDWriter 共用串口;LED 60Hz 发 L 帧
  6. SharedServices → ModeManager → switch_to(initial_manifest)
  7. Web 后台线程(可选)
  8. 主线程等待;串口运行中断开 → 3s 后自动退出并清理

scsynth 常驻;模式切换只 free/new SC group。SynthDef 启动时一次加载完。


8. 模式切换流程

浏览器: POST /api/mode {"manifest": "jam/lofi_1.toml"}
   ↓
ModeManager.switch_to():
  1. 取锁
  2. current_app.stop():
       - 停止自己的主循环线程
       - 取消订阅 SensorReader
       - 清空 LEDWriter 的数据源
       - sc.free_group(my_group)  ← 一次性杀光所有自己的 synth 节点
  3. 加载新 manifest
  4. 根据 manifest.mode 选 ModeApp 类(AmbientApp / JamApp)
  5. new_app = ModeAppClass(manifest, services)
  6. new_app.start():
       - sc.new_group() → 拿到自己的 group ID
       - 启动自己模式特定的初始 synth(如 master FX)
       - 订阅 SensorReader
       - 注册 LEDWriter 数据源
       - 启动主循环线程
  7. current_app = new_app
  8. 释放锁

整个切换过程在 100-300ms 量级。可以在 stop/start 之间加全局淡入淡出做更自然的过渡,但 prototype 阶段不必。

SC group 隔离是关键设计:每个模式启动时新建一个 SC group,所有自己的 synth 都 spawn 在这个 group 下。停止时 n_free 整个 group——所有声音立刻干净消失,不需要追踪每一个 synth ID。


9. 模式主循环结构

两个模式的主循环结构截然不同,这正是为什么不强行抽象统一接口的原因。

Ambient 主循环(20 Hz,reconcile-based)

loop @ 20Hz:
    frame = sensor.latest()
    occ = tracker.occupied(frame.matrix)       # 逻辑坐标 + 阈值
    state, events = state_model.update(occ)  # placed / removed 边沿
    result = mapper.map(state, events)
    voices.reconcile(result); voices.pump(t)
    leds.set_buffer(render_led(t, occ))

mapper 给出"声明”,voices 执行"调和”,scsynth 真正发声——这条链路保持每帧 20Hz 的稳定推进。

Jam 主循环(~200 Hz,连续时间 + 扫描触发)

Jam 不是 16 分音符 tick 上统一 emit(tick)。实际逻辑:

loop @ ~200Hz:
    frame = sensor.latest()
    delta = sensor_state.update(frame)     # occupied/placed/removed, Lo-Fi, control_active

    master_fx.set_lofi(delta.control_value)   # R0/R1 压力之和 → 0..1000,连续实时

    if not control_active:               # R0/R1 压力和 < CONTROL_SUM_MIN(含 100ms 释放 hold)
        reset transport/scanline/event(边沿); 空闲放石 preview(JAM_IDLE_PLACE_PREVIEW_VEL)
        render LED(全环呼吸 0–20); return

    transport.start() on first control      # 有控制石 → 开拍
    if not transport.playing:             # 曲终:等清空 R0/R1
        render LED; return

    prev_t, curr_t = transport.advance(dt)
    harmony.emit(prev_t, curr_t) → bridge   # ChordEvent,按时间区间
    drum.emit(prev_t, curr_t) → bridge      # 16 分 tick

    prev_a, curr_a, wrap = scanline.update(curr_t)
    optional: placed preview if JAM_LOOP_PLACE_PREVIEW_VEL > 0
    notes = event_engine.emit_sweep(...)    # 扫描线过扇区中心 → 旋律/bass
    bridge.handle(notes); LED scan hit / preview

    leds.set_buffer(jam_led.render(...))    # 播放:扫描尾迹 + 卡位呼吸;空闲:暗呼吸

控制石判定:control_active = (R0+R1 全格压力之和) ≥ CONTROL_SUM_MIN,与单格 threshold 独立。单格 threshold(默认 500)仍决定 occupied/placed。

音频总线(jam_master):旋律+bass → bus 2(gain 0.9)、和声 → 4(0.4)、鼓 → 6(0.62)→ 硬件 out 0。


10. Manifest 参数说明

10.1 通用参数(所有 manifest)

参数类型说明
modestring模式标识,目前为 "ambient" 或 "jam"
namestring此 manifest 的显示名,用于 Web 控制台
descriptionstring简短描述,可选

10.2 Ambient 模式参数

10.3 Jam 模式参数

[music] 块:

参数类型说明
bpmint节拍速度。120 为默认。可在运行时通过 Web 控制台调整。
keystring调性根音,例如 "C"、"G"、"F#"。改这一项即完成全局移调。
scalestring音阶类型,如 "major"、"natural_minor"、"dorian"。
master_volumefloat主输出音量,0.0 - 1.0。

[refs] 块(指向 data/ 下的具体配置):

参数类型说明
progressionstring和弦进行名,对应 data/progressions/<name>.yaml
melody_packstring旋律包名,对应 data/melodies/<name>/
drumsstring鼓配置名,对应 data/drums/<name>.yaml
ringsstringRing 角色 + 音色映射,对应 data/rings/<name>.yaml
lofi_mappingstringLo-Fi 强度到 SC 参数的映射曲线名

10.4 各 data 文件的字段

progressions/<name>.yaml — 和弦进行:

chords:
  - {id: "2m9",   bars: 2}
  - {id: "513",   bars: 2}
  - {id: "1maj9", bars: 2}
  - {id: "69",    bars: 2}

melodies/<name>/<chord_id>.yaml — 一个和弦下的旋律触发音表:

# 8 个 8 分音符位置上,各 ring 该响什么级数
R7: ["3^", "1^", "2^", "3^", "3^", "2^", "1^", "6"]
R6: ["1^", "5", "6", "1^", "1^", "5", "6", "3^"]
R5: ["6", "3", "4", "6", "6", "4", "3", "2"]
R4: ["4", "3", "2", "3", "4", "3", "2", "3"]
R3: ["4_", null, "6_", null, "4_", null, "6_", null]
R2: ["2_", null, "2_", null, "2_", null, "2_", null]

null 表示该位置不触发音。

(记号:X=X4, X^=X5, X_=X3, X__=X2(1=C时))

drums/<name>.yaml — 鼓 pattern 与循环序列:

patterns:
  A: {kick: [...], snare: [...], hihat: [...]}    # 16 个 16 分音符位
  B: {kick: [...], snare: [...], hihat: [...]}
  C: {kick: [...], snare: [...], hihat: [...]}
  D: {kick: [...], snare: [...], hihat: [...]}
sequence: [A, B, C, C, A, D, D, A]    # 整曲 8 遍的播放顺序

rings/<name>.yaml — Ring 角色 + SynthDef 名 + gate/gain(当前 lofi 摘录):

R0: {role: control, param: lofi_amount, range: [0, 1000]}
R1: {role: control, param: lofi_amount, range: [0, 1000]}
R2: {role: bass,   instrument: jam_sub_bass,   note_sec: 0.25, tail_sec: 0.1,  gain: 2.9}
R3: {role: bass,   instrument: jam_pluck_bass, note_sec: 0.5,  tail_sec: 0.15, gain: 3.1}
R4: {role: melody, instrument: jam_pad_pluck,  note_sec: 1.28, gain: 2.75}
R5: {role: melody, instrument: jam_rhodes,     note_sec: 0.74, gain: 3.45, params: {far: 1}}
R6: {role: melody, instrument: jam_rhodes,     note_sec: 0.82, gain: 3.45, params: {far: 0}}
R7: {role: melody, instrument: jam_marimba,    note_sec: 2.012, gain: 2.75}

lofi_mapping.yaml — Lo-Fi 0–1000 → jam_master 的 cutoff + drive(分段线性):

master:
  cutoff: {0: 14000, 250: 9500, 500: 6447, 750: 4376, 1000: 2425}
  drive:  {0: 0.0, 250: 0.00625, 500: 0.0125, 750: 0.01875, 1000: 0.025}

公式:cutoff = 14000·(420/14000)^(level/2000),drive = level/40000。和声/鼓总线增益不随 Lo-Fi 变化。


11. 开发环境

11.1 包管理:uv

项目使用 uv 管理 Python 环境与依赖。uv 是 Astral 出品的 Python 包管理工具,相比传统 pip + venv 速度快一个数量级,且能完整锁定依赖。

常用命令:

uv sync                        # 安装依赖
uv add <package>
uv run soundmat                  # 默认 manifest/jam/lofi_1.toml

依赖锁定文件 uv.lock 提交到版本控制,保证开发机与 Pi 部署环境一致。

11.2 SC 端编译

SC 端的 SynthDef 用 sclang 编译成 .scsyndef:

sclang sc/compile.scd

编译产物输出到 sc/compiled/,运行时由 scsynth 一次性加载。

11.3 离线开发(无硬件)

uv run soundmat --mock              # 手动 Web 垫面注入
uv run soundmat --mock recording.npz
uv run soundmat --no-sc             # 无音频,仅逻辑/LED
uv run soundmat --list-ports

mock_reader + Mat 页 /api/mock/cells;--mock 时自动 WIRE_SLICE_MIRROR=False(虚拟垫已是 CW slice)。串口打开失败也会转 mock。

scripts/replay_test.py 可离线回放录制矩阵。Web「录制传感」尚未实现。


12. 关键接口与协议

12.1 模式协议

任何模式只要实现以下四个方法就能被 ModeManager 管理:

class ModeApp(Protocol):
    def __init__(self, manifest: dict, services: SharedServices): ...
    def start(self) -> None: ...
    def stop(self) -> None: ...
    def status(self) -> dict: ...
    def set_param(self, key: str, value: Any) -> None: ...

不是严格的抽象基类,只是约定(duck typing)。

12.2 SharedServices

打包共享底层服务:

@dataclass
class SharedServices:
    sc: SCServerHandle
    sensor: SensorReader
    leds: LEDWriter
    osc: OSCClient
    serial_port: str | None = None
    serial_ready: bool = False

12.3 Jam 模式核心事件类型

@dataclass
class NoteEvent:
    ring: int
    degree: str | None = None
    voice: str | None = None       # 鼓: kick/snare/hihat
    velocity: float = 1.0
    sustain: float = 0.5           # 秒
    pan: float = 0.0
    when: float = 0.0
    source_slice: int | None = None  # LED 用


@dataclass
class ChordEvent:
    symbol: str
    degrees: tuple[str, ...]
    hold: float
    release_first: bool = True


@dataclass
class ParamEvent:
    param: str
    value: float


@dataclass
class SliceTick:
    slice: int
    timestamp: float
    did_wrap: bool = False


@dataclass
class SongPosition:
    chord_idx: int
    bar: int
    sub_loop: int
    bar_global: int = 0

13. 实现状态(相对本文档初稿)

已基本落地:core/、ambient/、jam/ 全链路、web/ Control+Mat、ModeManager 模式切换、Jam LED 扫描/呼吸/预览、S→L 校准、控制石 sum + 释放 hold、放石 preview、串口断开 3s 退出、scsynth 启动重试。

待完善或可选:传感录制、Ambient 上机样本完整性校验、模式切换淡入淡出、更丰富的 Debug 事件流。

上机步骤与校准项见仓库 SETUP.md。