# 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`)。它异步插一个 `