# guzheng
**Repository Path**: imflyfish/guzheng
## Basic Information
- **Project Name**: guzheng
- **Description**: 古箏 · 物理建模弹拨合成器
浏览器里的二十一根弦古筝。发声不靠采样音色库,而是用 AudioWorklet 实时跑 数字波导(Digital Waveguide)物理模型:弦被拨响后每一个采样点都在算,音高、 频散、拍音、衰减、木箱共鸣全部由物理量推出来。
零外部依赖、零构建步骤 —— 纯 HTML + CSS + JS,丢进任意静态服务器即可打开。
- **Primary Language**: JavaScript
- **License**: MulanPSL-2.0
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2026-09-18
- **Last Updated**: 2026-09-20
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# 古箏 · 物理建模弹拨合成器
浏览器里的**二十一根弦**古筝。发声不靠采样音色库,而是用 **AudioWorklet** 实时跑
数字波导(Digital Waveguide)物理模型:弦被拨响后每一个采样点都在算,音高、
频散、拍音、衰减、木箱共鸣全部由物理量推出来。
零外部依赖、零构建步骤 —— **纯 HTML + CSS + JS**,丢进任意静态服务器即可打开。
## 在线访问
**https://dev.321zou.com/guzheng/www**
无需安装、无需注册,打开即弹。第一次点弦(或按键)时启音 —— 浏览器要求用户手势
才能建 `AudioContext`,所以没有启动页,第一下就有声。
> 💡 建议用**手机或平板**打开:它是给手指设计的,触屏上有按弦、刮奏、摇指这些
> 键盘给不了的东西。PC 上也能弹,弦的右端会标出每根弦对应的键盘按键。
```
https://dev.321zou.com/guzheng/www/ → 入口页,自动换成琴页
https://dev.321zou.com/guzheng/www/main.html → 琴页本体(可单独访问/异地部署)
```
自建部署见 [快速开始](#快速开始)。
---
## 目录
- [它是什么](#它是什么)
- [在线访问](#在线访问)
- [快速开始](#快速开始)
- [功能一览](#功能一览)
- [演奏方式](#演奏方式)
- [软件架构](#软件架构)
- [物理模型要点](#物理模型要点)
- [三台引擎](#三台引擎)
- [面板参数](#面板参数)
- [曲目](#曲目)
- [二次开发](#二次开发)
- [已知约束与取舍](#已知约束与取舍)
---
## 它是什么
一台可在手机 / 平板上弹的古筝仿真器,同时是一份**物理建模的完整实现记录**。
- **音高是解出来的,不是查出来的**:环路总相位 = 2π 才有正确音高,代码逐弦解析
求解分数延迟全通 + 色散全通 + 一阶低通三者的相位贡献(基频误差 < 0.11 音分)。
- **不同机型的音高差异会被测出来补回去**:启动后静默跑一次逐弦离线渲染测频,
把机型 / 采样率带来的偏差逐弦补偿(见下文「自动调音」)。
- **三台引擎可 A/B 对比**:物理波导建模 / 1983 年原始的 Karplus-Strong /
由 21 弦真实录音反解的实测引擎(**出厂默认**)。同一组参数一键切换 ——
前两者是**时域波导递归**(阻尼由环路滤波器间接决定),第三者是
**模态加法合成**(每个谐波的幅度与衰减率逐条查表),
听得出"建模方式"本身带来的差别。
---
## 快速开始
### 1. 起一个静态服务器
音频处理器(AudioWorklet 源码)是用 `fetch` 取回的,**必须经 HTTP(S) 访问**。
直接双击本地 `index.html`(`file://`)会被浏览器安全策略拦掉。
```bash
cd guzheng
python3 -m http.server 8000
# 然后浏览器打开 http://localhost:8000/
```
任何等价方式均可(`npx serve`、nginx、对象存储静态托管……),只要能通过
HTTP 提供目录下的文件即可。想直接访问线上实例,见上方 [在线访问](#在线访问)。
### 2. 入口与琴页
站点根访问 `index.html` 时,它会立刻 `location.replace` 到 `main.html` 并保留
query / hash;没有 JS 的环境(或爬虫)则停在该页读到部署说明。所以:
- 正常用户:访问站点根即可。
- 想内嵌 / 指定入口:可直接访问 `main.html`。
### 3. 第一次触摸即启音
浏览器要求用户手势才能建 `AudioContext`,所以本琴**没有启动页**:第一次点弦
(或按键)时顺手把音频起起来,并补发这一击 —— 第一下就有声。若音频起不来
(`file://`、旧浏览器、处理器加载失败),当场的报错卡会说明原因,那颗按钮
可以重试。
---
## 功能一览
| 分类 | 能力 |
| --- | --- |
| 发声 | 物理波导建模(默认)/ Karplus-Strong 对照组,一键切换 A/B |
| 定弦 | **21 弦 · D 调五声**,凡四个八度 D2–D6;D / G / C / F / A 五种调式依实法移柱转调 |
| 演奏 | 触屏拨弦、横向刮奏、按弦区揉弦与滑音、花指、琶音、撮弦、泛音、摇指、键盘演奏 |
| 调音 | 自动调音(开机静默测量机型差异并逐弦补偿)、调音台逐弦校音/微调、基准音高、手工整度 |
| 录音 | 自动录制(拨弦即录、静音自动收尾)、手动录制、暂停续录、页内回放、导出 `webm` / `WAV` |
| 音色 | 12 个滑杆 + 4 个开关,全部对应可推导的物理量(见「面板参数」) |
| 分享 | 配置短码(`GZ1.…`)导出/导入,只包含你改动过的项;一键恢复出厂 |
| 移动端 | 视觉视口补偿(规避国产浏览器原生广告栏遮挡)、横屏沉浸模式、深夜音量一拉即轻 |
| 依赖 | **无**。无 npm、无打包、无 CDN、无字体/图标外部引用 |
---
## 演奏方式
### 触屏
| 手势 | 效果 |
| --- | --- |
| 点 **演奏区**(雁柱右侧) | 拨弦。落点越靠近岳山音色越亮,靠近雁柱越浑厚 |
| 演奏区**横向拖动** | 刮奏;纵向划过琴面则连续拨响经过的弦 |
| 点 **按弦区**(雁柱左侧) | 按弦,压得越深音越高(上限由「按弦上限」定) |
| 按弦区**上下拖动** | 滑音;快速抖动即揉弦 |
| **双击**某弦 | 摇指(持续数秒后自然收束) |
### 键盘
```
低音区 Z X C V B N M , . / (第 21 → 12 弦)
高音区 Q W E R T Y U I O P ; (第 11 → 1 弦)
Shift + 键 加强力度
空格 止息(全部按弦止音)
Esc 逐层退出:先收「精调参数」折叠,再关抽屉
```
### 底部工具条
一条栏装两套东西,点最左的 **♪ / 🎙** 切换:
- **播放模式** ♪:`▶/❚❚` 播放与停止 · 进度条可拖动定位 · `▼` 存录音 · `▷` 回放刚录的那段
- **录制模式** 🎙:`●/■` 手动起录与终止 · 显示已录时长 · 同两条录音键
---
## 软件架构
### 目录结构
```
guzheng/
├── index.html 入口:UA/环境分流 → main.html(无 JS 时显示部署说明)
├── main.html 琴页本体:全部 DOM + 按序加载脚本
├── css/
│ └── guzheng.css 全部样式(视口锁布局、抽屉、工具条、滑杆、深色主题)
└── js/
├── engine/
│ ├── registry.js 引擎注册表:loadEngine() → Blob URL;下拉填充
│ ├── physics.worklet.js 主引擎:分谐波波导 + 双偏振 + 面板模态 + 缠层金属嗡鸣
│ ├── ks.worklet.js 对照组:Karplus-Strong 经典波导(故意做素)
│ └── measured.worklet.js 实测引擎:模态加法合成,逐谐波查表(由 21 弦实录反解)
├── core.js 乐器定义 · 状态 P · 音频初始化 · 输出链 · 演奏动作
├── ui/
│ ├── canvas.js 几何 layout() · 视觉视口探测 · 琴面绘制(含雁柱/弦序/唱名)
│ ├── control.js 滑杆与开关绑定 · 调音台 · 引擎切换 · 配置导入导出
│ ├── autotune.js 自动调音:离线渲染 + 相位差测频 + 逐弦补偿
│ └── rec.js 自动/手动录制 · 分段暂停续录 · 回放 · webm/WAV 导出
├── score/
│ ├── player.js 演奏框架:事件表 · 进度条 · 曲目表 · 名曲通用构件
│ └── pieces/ 八首曲子的谱面数据与编排(一音一文件)
└── app.js 启动流程 · 抽屉 · 面板 · 视口兜底轮询 · 渲染循环
```
### 加载顺序(有硬依赖,不要调换)
```html
registry.js ← 必须先于 core.js:initAudio 会调用 loadEngine()
core.js ← 乐器定义 / 状态 / 音频链 / pluck 等动作
ui/canvas.js ← layout() 与绘制
ui/control.js ← 绑定滑杆(加载瞬间即执行 bindRange)
ui/autotune.js ← 自动调音
ui/rec.js ← 声明 MODE,必须早于 player.js
score/player.js ← tpToggle 会在点击时读 MODE
score/pieces/*.js ← 各曲编排
app.js ← 最后:启动渲染循环,读取全部构件
```
### 全局脚本模式(关键约定)
所有 JS **不是 ES Module**,而是按 ``
3. 在 `js/score/player.js` 的 `PIECES` 表里加一行 `{ id, nm: '曲名', fn: demoXx }`
`PIECES` 是曲目列表、时长回填、播放高亮的**唯一数据源**:新增后中段列表与
底部进度条的时长会自动同步。
> ⚠ 曲目列表的时长是在该曲首次弹响时由 `runSeq` 回填的(与进度条同源),
> 不另外维护一份副本。
### 新增一台引擎(换/加算法)
只改 `js/engine/registry.js` 这一张表:
```js
window.ENGINES.myAlgo = {
label: '我的算法',
hint: '一句话说明',
processorName: 'myalgo', // ⚠ 必须唯一,同名 registerProcessor 会抛错
src: 'js/engine/myalgo.worklet.js',
edgeReadout: v => v.toFixed(2),
edgeHint: '刃感的含义(按本引擎的物理含义写)',
params: [/* 本引擎认的参数 key,不在表内的滑杆会自动收起 */],
ignore: [/* 认但用不到的参数,列出来便于测试区分 */]
};
```
下拉框 (`#selEngine`) 由注册表同源填充,新增一项即自动多一个选项。
切换语义是**停音 → 重建 node → 原样重放参数**:参数不重置,
这样 A/B 对比才是公平的(两个算法 + 两组参数同时变就什么都说明不了)。
`AudioWorkletNode` 的构造来源变了,**物理上不可能无缝热替换**,正在响的弦必然被切断。
新引擎的处理器源码应当**沿用同一套报文协议**(`pluck` / `param` / `freq` /
`body` / `gain` / `bend` / `vib` / `damp` / `all` / `slide`),
用不到的字段静默忽略、绝不报错。
### 引擎报文协议(minimal 子集)
```js
{ type: 'pluck', s, p, v, sharp, noise, harm, dir, edge, t, bend, vib }
{ type: 'param', i, t60, rho, metal }
{ type: 'freq', s, f, b, g, c, k, cents }
{ type: 'body', mix, couple, echo }
{ type: 'gain', g }
{ type: 'bend', s, bend, press } { type: 'release', s }
{ type: 'vib', s, d, r }
{ type: 'damp', s, k } { type: 'slide', r }
{ type: 'all' }
```
### 调试钩子
| 符号 | 用途 |
| --- | --- |
| `window.ENGINE` / `window.ENGINES` | 当前引擎名 / 注册表 |
| `window.engineFor(name)` | 取引擎描述(未知名字回落 physics,绝不返回 undefined) |
| `window.loadEngine(name)` | 取该引擎的 Blob URL(按引擎名缓存,不 revoke) |
| `window.__loadWorklet(name)` | 源码获取的**唯一出口**,可被测试替换以穿透整条加载链路 |
| `window.__workletSource` | **当前**引擎的源码文本 |
| `window.fillEngineSelect()` | 把注册表填进 `#selEngine` |
| `window.AUTOTUNE` | 自动调音状态(`state` / `before` / `after` / `sig` …) |
| `window.P` / `window.N` / `window.GEO` | 运行参数 / 弦数(21)/ 画布几何 |
`P` 里的值与 `main.html` 的 `value="…"` 出厂值必须保持一致,多处注释写明了
这条纪律;配置导入 / 恢复出厂都走 `setParam()` 这一条通道(它会 `dispatchEvent`
一次 `input`),不新增第二条改参数的路径。
### 配置短码
```
GZ1.bright=0.62,pos=0.14,eng=ks,nail=0
```
- 前缀 `GZ1` 是版本标记,将来改名/删参数时导入端能明确拒绝而不是静默乱套
- **只写与出厂值不同的项** —— 一份长码 = 一次大改动,一眼可见
- 导入时按滑杆 `range` 夹取,超范围值进不来;不认识的键会回报告知
- 引擎本身也进短码,否则收到配置的人用着另一个算法,听到的音色完全不是一回事
---
## 已知约束与取舍
1. **必须经 HTTP(S) 打开**。AudioWorklet 源码走 `fetch` 取回,`file://` 会被
浏览器安全策略拦掉。报错卡会区分"本地双击"与"经 HTTP 打开"两种情况给出对应提示。
2. **全局脚本模式**,不是 ESM。worklet 侧的 `Blob` 加载拿不到相对路径基准,
引入 ESM 需要整个项目改造 —— 维持现状是既定取舍。
3. **切换算法不可能无缝**。`AudioWorkletNode` 的构造来源变了,只能销毁重建,
正在响的弦会被切断;面板文案已明说这一点,不假装热替换。
4. **移动端要补偿原生 UI**。国产手机浏览器(vivo/OPPO/华为/UC/QQ)会在页面上方
推出**原生广告栏**:它是浏览器自己画的 UI,不在 DOM 里,只压缩 `visualViewport`
而不动布局视口,任何 DOM 查询都看不见它。所以只能从 `visualViewport` 读,
并对 `resize` + `scroll` + 1 s 兜底轮询三重覆盖(实测存在"广告栏出来了、
事件一个都不发"的情况)。取布局视口与视觉视口的**小值**,并挡住 `NaN`
(`NaN` 传进 `createLinearGradient` 会让整台琴画不出来)。
5. **录音格式默认 `webm`/opus**,微信等场景请用「⤓ 存 WAV」(体积约为 opus 的 10 倍)。
浏览器不能录 MP3;转码需引入编码器,与"零外部依赖的单包部署"冲突。
6. **录制的暂停是"分段"而非 `MediaRecorder.pause()`**:后者会让暂停期间无法回放
*完整* 内容,而"暂停是为了先听一遍"正是用户的目的。代价是要维护分段账本。
7. **内嵌百度统计**(`hm.baidu.com`)。它异步插一个 `