




说明:本项目没有引入任何第三方 npm 库(
packNpmRelationList为空、无package.json/node_modules),全部依赖微信小程序原生 API + 自研算法。因此下文「关键三方能力」指平台提供的原生 API / 运行时能力。
| 维度 | 选型 | 说明 |
|---|---|---|
| 平台 | 微信小程序 | appid: wxcc484689ab84d1e7 |
| 渲染引擎 | Skyline ("renderer": "skyline") |
新渲染架构,默认 display:block / content-box |
| 组件框架 | glass-easel ("componentFramework") |
新一代组件框架 |
| 模块系统 | ES Module (import/export) |
由开发者工具编译,无构建工具 |
| 状态管理 | app.globalData + wx.setStorageSync |
无 Redux/Vuex 等三方状态库 |
| 依赖 | 无 npm 三方包 | 纯本地算法实现 |
关键全局配置 app.json:
{
"renderer": "skyline",
"componentFramework": "glass-easel",
"rendererOptions": {
"skyline": {
"defaultDisplayBlock": true,
"defaultContentBox": true,
"tagNameStyleIsolation": "legacy",
"disableABTest": true
}
},
"lazyCodeLoading": "requiredComponents",
"tabBar": { "custom": true, "list": [/* 4 个 tab */] }
}
utils/pitchDetect.js不用简单过零率,而是完整实现 de Cheveigné & Kawahara (2002) 的 YIN 流程:
输入 Float32 帧(2048 样本, 44.1kHz)
→ ① RMS 能量门限(过滤静音/底噪)
→ ② 差分函数 d(τ) = Σ (x[i] - x[i+τ])² // 抗倍频歧义
→ ③ 累积均值归一化差分 d'(τ) = d(τ)·τ / Σd // 与幅度无关
→ ④ 首个低于阈值的 τ,再向下找局部最小
→ ⑤ 抛物线插值得到亚样本精度
→ freq = sampleRate / τ'
// 抛物线插值(亚样本精度,精度 ~±0.1 cent)
const denom = 2 * (2 * s1 - s2 - s0)
betterTau = tauEstimate + (s2 - s0) / denom
关键参数:threshold=0.2、minFreq=50、maxFreq=2000、minRms=0.003。
utils/noteUtils.js十二平均律,全部基于 A4 = 440Hz:
centsBetween(target, actual) = 1200 * log2(actual / target)
clampCents(c) = clamp(c, -50, 50)
isInTune(c, tol = 5) = |c| <= tol // ±5 cent 视为调准
frequencyToNote(f) // 半音数 → 音符名 + 八度 + 偏差音分
highPass(buffer, 0.98) // ≈144Hz,保留 E2(82)/A2(110)/D3(147) 低音弦
lowPass(buffer, 0.5) // ≈7kHz,保留谐波、滤高频噪声
computeSpectrumMagnitudes(buffer, fftSize = 512)
// X[k] = Σ x[n]·e^(-j2πkn/N),仅取前 N/2,仅用于可视化
utils/instrumentRecognizer.js收集最近 30 帧音高 → 取中位数(抗噪声/离群值)→ 按频段粗分类 → 在对应分类预设中按 log2 距离匹配最近琴弦。
utils/metronome.js经典 Web Audio lookahead scheduler,避免 setInterval 抖动:
_lookahead = 25ms // 每 25ms 唤醒一次
while (nextTickTime < now + 200) { // 提前排 200ms 内的拍
setTimeout(() => playClick(isAccent), nextTickTime - now)
nextTickTime += 60000 / bpm
}
_scheduleTimer = setTimeout(() => this._schedule(), this._lookahead)
wx.getRecorderManager()utils/audioEngine.js 的核心,注意 frameSize 单位是 KB(坑点):
this.recorderManager = wx.getRecorderManager()
this.recorderManager.start({
format: 'PCM',
sampleRate: 44100,
numberOfChannels: 1,
frameSize: frameSamples * 2 / 1024, // 样本数 → KB
encodeBitRate: 128000,
duration: 600000
})
// 帧回调:Int16 → Float32,并按「固定样本窗口」重新切分
this.recorderManager.onFrameRecorded(res => {
const view = new DataView(res.frameBuffer)
const len = res.frameBuffer.byteLength >> 1
for (let i = 0; i < len; i++) buf[bufLen++] = view.getInt16(i * 2, true) / 32768
while (bufLen >= frameSamples) { /* 回调一个窗口 */ }
})
this.recorderManager.onStop(...) // 达时长上限/中断 → 自动续录
this.recorderManager.onError(...) // 权限/采集错误回调
工程要点:官方帧大小机型不一,引擎内部用「累积缓冲 + 固定窗口切分」,保证分析窗口与 UI 刷新率稳定;onStop 里自动重启实现长时间连续监听。
InnerAudioContext + 合成 WAV真机兼容性最好(覆盖 iOS 静音键、部分安卓 WebAudio 无声):
// ① 手写 44 字节 WAV 头,把 Float32 采样本编码为 16bit 单声道 WAV
const wav = encodeWav(samples, 44100)
wx.getFileSystemManager().writeFile({
filePath: `${wx.env.USER_DATA_PATH}/tone_xxx.wav`,
data: wav,
success: () => { /* 缓存 + 播放 */ }
})
// ② InnerAudioContext 播放
const audio = wx.createInnerAudioContext()
audio.obeyMuteSwitch = false // iOS 静音键下也发声
audio.volume = volume
audio.src = filePath
audio.play()
// ③ 全局音频策略(只需设置一次)
wx.setInnerAudioOption({ obeyMuteSwitch: false, mixWithOther: true })
低频增强:freq < 200Hz 时叠加 2/3/4 次谐波并提升增益,解决手机小扬声器低音听不清。
wx.createWebAudioContext()WAV 写入失败或需连续发声时用振荡器:
const ctx = wx.createWebAudioContext()
ctx.resume().catch(() => {}) // 真机首次播放必须 resume
const osc = ctx.createOscillator()
osc.type = 'sine'
osc.frequency.value = freq
const gain = ctx.createGain()
gain.gain.setValueAtTime(0.0001, ctx.currentTime) // exponentialRamp 不能到 0
gain.gain.exponentialRampToValueAtTime(vol, ctx.currentTime + 0.03)
osc.connect(gain); gain.connect(ctx.destination)
osc.start(); osc.stop(ctx.currentTime + duration + 0.05)
createSelectorQuery().fields({ node: true, size: true })tuner-dial / tuner-strobe / tuner-spectrum 三个组件共用此模式:
this.createSelectorQuery().in(this)
.select('#dial-canvas')
.fields({ node: true, size: true })
.exec(res => {
if (!res?.[0]?.node) { setTimeout(() => this.draw(), 50); return } // 异步渲染兜底
const canvas = res[0].node
const ctx = canvas.getContext('2d')
const dpr = (wx.getWindowInfo() || wx.getSystemInfoSync()).pixelRatio || 2
canvas.width = res[0].width * dpr
canvas.height = res[0].height * dpr
ctx.scale(dpr, dpr) // HiDPI 适配
// ... 绘制刻度弧 / 指针 / LED / 频谱柱
})
-50~+50 cent → 角度)+ 渐变指针。setTimeout 自驱动,速度随 |cents| 变化。wx.setStorageSync / getStorageSyncutils/storage.js 统一封装,历史记录上限 200 条:
wx.setStorageSync('autoTuner:history', list)
wx.getStorageSync('autoTuner:history') || []
custom-tab-bar/ + wx.switchTab顶部 "tabBar": { "custom": true } + 根目录 custom-tab-bar/index.*:
// 页面侧同步选中态(Skyline 下 getTabBar 为异步回调,需兼容两种返回)
_syncTabBar() {
const instance = this.getTabBar(tabBar => {
if (tabBar?.setSelected) tabBar.setSelected(2, theme)
})
if (instance?.setSelected) instance.setSelected(2, theme)
}
Skyline 下 tabBar 根节点需 position: absolute; pointer-events: auto 才能常驻并响应点击。
| API | 用途 |
|---|---|
wx.showModal / wx.showToast |
权限引导、操作反馈 |
wx.openSetting |
权限被拒后跳转设置 |
wx.getWindowInfo() (回退 getSystemInfoSync) |
取 pixelRatio 做 HiDPI |
wx.setInnerAudioOption |
静音键策略、混音 |
wx.getFileSystemManager |
WAV 写文件 / 淘汰删除 |
麦克风
→ RecorderManager(frameBuffer PCM, Int16)
→ AudioEngine 累积成固定窗口(2048)
→ highPass(0.98) + lowPass(0.5)
→ ┌─ YIN detectPitch → 频率
└─ DFT computeSpectrum → 频谱(仅频谱模式)
→ centsBetween(目标, 实测) + clampCents
→ UI(data) → Canvas 表盘/频闪/频谱
→ 会话结束 → appendHistory 写入本地存储
| 问题 | 原因 | 解决 |
|---|---|---|
| 录音帧大小不稳 | frameSize 单位 KB、机型差异 |
引擎累积缓冲 + 固定样本窗切分 |
| 长监听中断 | 达 duration 触发 onStop |
onStop 内自动续录 |
| iOS 静音键无声 | 系统静音策略 | obeyMuteSwitch:false + setInnerAudioOption |
| 部分安卓 WebAudio 无声 | 上下文未激活 | 首次强制 resume,失败回退 WAV |
| 低音参考音听不清 | 小扬声器低频衰减 | 叠加 2/3/4 次谐波 + 增益 |
WXML 不支持 toFixed |
模板表达式受限 | 数据在 JS 侧全部预计算 |
| Canvas 未就绪 | 节点异步渲染 | exec 校验 node/size,setTimeout 重试 |
| 后台录音耗电 | 页面隐藏未停 | onHide/onUnload 显式 stopCapture |
| 首帧闪烁 | Skyline 首次渲染只用 data 初值 |
页面 data 用模块加载时计算的值初始化 |
| 切 tab 闪动 | 原用 navigateTo 整页跳转 |
改自定义 tabBar + switchTab(无转场、缓存复用) |
一句话总结:AutoTuner 在零三方依赖的前提下,用 RecorderManager 取原始 PCM,自研 YIN + DFT 算法做高精度音高检测,再用 InnerAudioContext/WebAudio + Canvas 2D + 本地存储 完成「采音 → 分析 → 可视化 → 参考音 → 记录」的完整闭环,核心难点集中在真机音频兼容性与 Skyline 首帧渲染两处。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。