同样是 440 Hz,为什么听起来不一样?我用华为云码道做了一个声音实验室
华为云码道 CodeArts 体验入口:
https://developer.huaweicloud.com/codeartsco.html?source=dmzntgwatomgit1&sourcead=dmzntgwatomgithdAtomGit 项目仓库:
https://atomgit.com/qq_40202349/sound-lab
把频率固定在 440 Hz,先播放正弦波,再切到方波。数字没变,声音却有了明显区别。频谱里,基频还在原来的位置,右边多出了几座峰。
这次我用华为云码道做了一个“声音实验室”,把这几种变化放到同一个网页里比较。固定频率换波形,或者保持波形不变调音量,听到的变化都能在时域波形和频谱中对照。下面是修复后的运行效果:
图1:440 Hz 方波的时域形状,以及基频之外的谐波。
项目用了 Vue 3、TypeScript、Vite,以及浏览器自带的 Web Audio API 和 Canvas。声音在浏览器里合成,不需要上传音频,也不需要申请麦克风权限。
给码道的需求:三个声音实验
先在 AtomGit 网页端通过码道创建项目仓库,再转到本地码道开发。
图2:向码道提交建仓需求。
给码道的开发需求分成三个实验:
- 改频率,听音高变化,看同一段时间里波形变密还是变疏。
- 改音量,看波形振幅变化,纵轴固定,不能自动把每条波形拉到一样高。
- 固定频率,切换正弦波、三角波和方波,比较音色与谐波。
频率范围设为 110–880 Hz。页面有播放、停止和重置按钮,第一次发声必须由用户点击。三个实验入口负责加载参数和操作指引,让使用者知道这一次应该改哪个值、观察哪张图。
需求里同时约定了图形和播放行为:
波形和频谱必须来自实际音频数据,不能用随机动画或预画图形冒充。
时域图使用固定时间范围与幅度刻度,让频率和音量的变化能直观看出来。
停止状态要明确,修改参数和切换实验不应意外开始播放。
启停、频率和增益变化做平滑处理,减少突变造成的爆音。
界面则按小型实验仪器来做:深色背景、清晰网格、青绿色波形和少量暖色数值,控制区与两张图放在一起。图形旁边保留必要的单位和说明,读者调整参数时能就近找到变化。
交付要求也一并写进需求:创建可运行的项目文件,完成测试和构建,再启动本地预览。首版交付时,声音合成、两张图、三个实验入口和运行说明都已经有了。
初版 75 项测试通过,换个顺序却出了问题
码道的首版报告列出了 7 个测试文件、75 项通过,以及生产构建成功。报告还把自动化测试、浏览器画面和实际听感分开记录,明确写了听感仍需人工确认。
图3:初版 75 项测试通过,报告单独注明听感需要人工确认。
我在本机按三个实验的指引试听:频率升高时音调变高,音量调低时声音变轻,切换波形也确实能听出区别。
随后借助 Codex 做独立复核,重新运行现有测试,仍是 75 项通过,类型检查与构建也通过。浏览器检查除了操作控件,还读取了真实音频节点,核对界面数值和实际参数是否相同。
按照页面提示,先播放、再调参数,三个实验都能工作。把顺序换成“刷新页面,先选择比较音高,再第一次播放”,界面显示 220 Hz,读取实际振荡器参数,却还是 440 Hz。
继续沿着这个顺序检查,先设置 660 Hz、10% 音量和方波,再播放,实际仍是默认的 440 Hz、60% 音量和正弦波。甚至先把音量调到 0,首次播放也仍然产生了非零音频信号。
再做一个对照:先播放,等音频引擎建立以后,再设置同样的 660 Hz、10% 音量和方波,三个参数就都能正确生效。相同设置在播放后能用,排查范围就缩到了首次初始化。
初版在点击播放时才创建引擎,却始终传入DEFAULT_PARAMS。播放前调参数时,引擎还不存在,负责同步的watch没有对象可更新;等引擎建立起来,此前的参数变化也不会自动重放一遍。
频率数字框也有问题。全选原来的数值,逐字输入660:第一个6被立即收窄为下限110,后续输入又继续触发边界处理,最后数字框停在了8800,滑杆实际是880。
这个问题用一次性填入660的脚本不容易发现,按键一位一位输入才看得出来。
图4:同样输入 660,初版会改写输入,修复后在失焦时正确提交。
桌面图表还有一处叠字:频谱末尾的5000和“频率 / Hz”画在同一条基线上,时域图也有类似问题。这些操作步骤和截图一起交给码道,作为下一轮修改的依据。
让码道沿着复现步骤修复
这轮只改参数同步、数字输入和桌面坐标文字。反馈里写明了怎么复现,以及希望改成什么样:
刷新后先选“比较音高”,首次播放应采用界面上的 220 Hz;先把音量设为 0,首次播放也要保持静音。
数字框允许暂时清空,输入中的6、66先保留,按 Enter 或失焦后再校验;滑杆继续实时生效。
为桌面图表的单位留出独立位置,避免与末尾刻度叠在一起。
同时要求补对应的回归验证,尤其是首次播放,要核对真实音频参数。只截一张显示 220 Hz 的页面,无法证明实际振荡器也使用了 220 Hz。
useAudioLab.ts用params保存界面当前选择,用engine保存音频引擎。修复需要接上两段逻辑:首次创建时读取当前参数,创建之后继续监听变化。下面摘出初始化和频率监听,省略音量、波形的同类监听及错误处理:
functionensureEngine():AudioEngine{if(!engine.value){engine.value=newAudioEngine({...params},fftSize.value)}returnengine.value}watch(()=>params.frequency,(value)=>engine.value?.setFrequency(value),)watch负责后续同步:频率一变,就把新值传给已经存在的引擎。?.让引擎尚未创建时跳过调用,所以播放前调参数不会报错,也不会触发声音,这时变化只保存在params里。
首次创建引擎时,{ ...params }复制当前的整组参数,替换原先传入的DEFAULT_PARAMS。假如用户先选了 220 Hz,这份快照就是 220 Hz;如果又把音量调成 0,引擎拿到的音量也会是 0。
ensureEngine()本身只创建 JavaScript 对象,真正建立或恢复AudioContext仍由播放事件中的start()完成。修改参数不会提前初始化音频。
ControlPanel.vue则把“正在编辑的文本”和“已经提交的频率”分开保存。下面节选草稿更新与提交逻辑,省略事件绑定、焦点处理和外部参数同步:
constfrequencyDraft=ref(String(props.frequency))leteditingFrequency=falsefunctiononFrequencyDraftInput(event:Event):void{editingFrequency=truefrequencyDraft.value=(event.targetasHTMLInputElement).value}functioncommitFrequency():void{constraw=frequencyDraft.value.trim()constparsed=Number(raw)constnext=raw!==''&&Number.isFinite(parsed)?clampFrequency(parsed):props.frequency frequencyDraft.value=String(next)if(next!==props.frequency)emit('update:frequency',next)}输入框的:value绑定frequencyDraft,每次input只改草稿。于是输入660时,6、66可以暂时留在框里,滑杆和实际声音仍保持上一次提交的频率。按 Enter 或失焦后,再调用commitFrequency(),通过update:frequency把结果传回上层。
提交时要先判断文本是否为空:Number('')会得到0,如果直接交给范围校验,清空输入框就会被误当成要设置最低频率。当前逻辑让空值回退到原频率,合法数字再由clampFrequency收窄到 110–880 Hz。滑杆每次给出的值已经在范围内,仍然直接更新,无需等待提交。
最后处理图表叠字。以频谱为例,刻度文字放在y + height + 12,横轴单位放在y + height + 26,两者错开 14 个像素;底部预留 34 个像素容纳这两行。纵轴名称挪到绘图区左上角,避开外侧的顶部数字。码道的修复记录如下:
图5:码道对话中的修复记录(局部),包含改动和对应验证。
码道补了 7 项回归测试:3 项检查首次播放前的参数,4 项检查数字框逐字输入、清空、越界和回车提交。随后独立复核重新走原来的操作路径,结果如下:
| 复核操作 | 修复后的实际结果 |
|---|---|
| 先选“比较音高”,再首次播放 | 界面与振荡器均为 220 Hz |
| 先设 660 Hz、10% 音量、方波,再首次播放 | 振荡器为 660 Hz、方波,音量增益为 0.05 |
| 先把音量设为 0,再首次播放 | 音量增益、采样峰值和 RMS 均为 0 |
数字框逐字输入660 | 依次保留6、66、660,失焦后滑杆同步到 660 |
| 查看桌面图表 | 单位与末尾刻度不再重叠 |
其中 10% 对应增益 0.05,是因为界面音量还要乘以项目设置的最大增益 0.5。RMS 是均方根值,用来描述这段采样的有效幅度;音量为 0 时,它和采样峰值都应为 0。
这些浏览器检查读取了真实音频节点,运行时关闭了设备出声,验证的是信号数据;听感依据是前面的本机试听。独立重跑测试得到 7 个文件、82 项通过,类型检查与生产构建通过。应用代码和修复由本地码道完成,需求与试听由我负责,独立复核和素材整理借助 Codex。
修复后,用三个实验看声音
接下来三段 GIF 都录自修复后的本地页面。GIF 没有声音,可以先看图形变化,再在自己的电脑上听。
先比较音高。选正弦波,把频率从 220 Hz 调到 880 Hz,再调回来。时域图的时间窗口不变,频率升高后,里面容纳的周期更多,曲线就变密了;频谱的主峰也向右移动。
图6:220–880 Hz 的频率变化,音量与波形类型保持不变。
留意横轴,它没有跟着频率自动缩放:本次环境采样率为 48000 Hz,一帧读取 2048 个采样,显示的时间窗口约为 42.7 ms。220 Hz 升到 440 Hz,周期减半,同一段时间里就会出现约两倍的波形周期。
再比较音量。频率固定为 440 Hz,只把音量从 10% 调到 80%。波形的周期基本不变,上下振幅明显变大。
图7:音量从 10% 调到 80%,波形纵轴始终使用相同刻度。
这里不能让纵轴自动缩放。如果每一帧都把当前最高点放大到顶格,10% 和 80% 的波形会看起来差不多高,音量调整带来的幅度差异就被抹掉了。
在当前增益设置下,正弦波的 10% 音量对应约 0.05 的峰值幅度,80% 对应约 0.4。保持频率和波形不变,就能把变化归到输出幅度上。不过,波形幅度增大几倍,并不等于人耳感觉也响了同样的倍数。
最后比较音色。频率保持 440 Hz、音量保持 60%,依次切换正弦波、三角波和方波。正弦波主要集中在基频附近;换成三角波、方波以后,基频之外出现了更明显的奇次谐波,方波的高次谐波尤其容易看到。
图8:频率和音量设置相同,切换波形观察谐波分布。
440 Hz 表示基频,声音里还可能有其他频率成分。它们的强弱不同,听起来就会有不同的音色。另外,同一个音量设置下,不同波形的主观响度也可能不同;这次只固定了滑杆数值,并没有做严格的等响度比较。
观察时可以先找 440 Hz 附近的主峰,再看 1320 Hz、2200 Hz 附近,也就是基频的 3 倍和 5 倍位置。三角波与方波都有奇次谐波,但这些峰的强弱不同。这样再回头看时域图,尖角、平顶和光滑曲线就能与频谱里的差别联系起来。
声音、波形和频谱怎样联动
拖动滑杆时,控件先更新useAudioLab.ts中的参数,监听把变化交给AudioEngine.ts,音频节点产生新信号。两张 Canvas 读取分析器的数据,再换算成坐标。
连接音频节点
音频节点在AudioEngine.ts的buildGraph()中创建、设置并连接。以下是代码节选,ctx是已经创建或恢复的音频上下文,省略重复创建保护、分析器显示配置和节点引用保存:
constoscillator=ctx.createOscillator()constvolumeGain=ctx.createGain()constmasterGain=ctx.createGain()constanalyser=ctx.createAnalyser()analyser.fftSize=this.fftSizeValue oscillator.type=this.params.waveform oscillator.frequency.value=this.params.frequency volumeGain.gain.value=this.params.volume*MASTER_MAX_GAINmasterGain.gain.value=0oscillator.connect(volumeGain)volumeGain.connect(masterGain)masterGain.connect(analyser)analyser.connect(ctx.destination)oscillator.start()oscillator负责连续生成波形,type决定正弦波、三角波还是方波,frequency决定每秒重复多少次。它后面的volumeGain把采样乘以音量系数,例如 10% 音量对应0.1 × 0.5 = 0.05。信号继续经过masterGain,再交给分析器和音频输出。
分析器接在两个增益节点后面,才能读到音量调整和停止后的信号。如果接在音量节点之前,听到的声音变小了,图形却仍可能读到未经衰减的信号。
上面已经启动振荡器,但masterGain初始为 0,因此这时输出仍然静音。播放流程再用linearRampToValueAtTime(1, now + 0.02),让总增益在 20 ms 内升到 1;停止时平滑降回 0,用户选择的音量保存在另一个增益节点中。
播放后的频率、音量调整使用setTargetAtTime,以 0.012 秒的时间常数逐渐靠近目标。这个参数控制变化快慢,并不表示 12 ms 后精确到达终值。切换波形时还会短暂降低总增益再恢复,以减轻突变。停止时保留节点方便重播,组件销毁时再断开连接并关闭音频上下文。
时域图:把采样值换成坐标
WaveformCanvas.vue用一个长度为fftSize的Float32Array接收采样,每次绘制都复用这个缓冲区。下面把读取和绘制代码放在一起,省略网格、配色与空节点检查;x、y、width、height是绘图区的位置和尺寸,分析器与缓冲区已经就绪:
constAMPLITUDE_RANGE=1constcenterY=y+height/2consthalfHeight=height/2analyser.getFloatTimeDomainData(buffer)constn=buffer.length ctx.beginPath()for(leti=0;i<n;i+=1){letvalue=buffer[i]if(!Number.isFinite(value))value=0if(value>AMPLITUDE_RANGE)value=AMPLITUDE_RANGEelseif(value<-AMPLITUDE_RANGE)value=-AMPLITUDE_RANGEconstpx=x+(i/(n-1))*widthconstpy=centerY-(value/AMPLITUDE_RANGE)*halfHeightif(i===0)ctx.moveTo(px,py)elsectx.lineTo(px,py)}ctx.stroke()横坐标按采样顺序排列:第一个点在左边,最后一个点在右边。相邻采样的时间间隔由实际采样率决定,横轴标签也按fftSize / sampleRate换算成毫秒,所以改频率时不必改变绘图区宽度。
纵坐标则以中线代表 0,正值向上、负值向下。Canvas 的纵坐标向下增大,因此公式里用减号。假设绘图区高 200 像素,半高就是 100;采样幅度为 0.05 时偏离中线约 5 像素,为 0.4 时偏离约 40 像素,正好对应前面音量实验中的差别。
公式的分母固定为AMPLITUDE_RANGE = 1,不会随当前一帧的最大值变化,小幅度信号也就不会被放大到顶格。边界处理只把异常或超出刻度的值收回可绘范围,曲线仍来自实际采样。
频谱:把频点换成 Hz 和像素
频谱使用同一个分析器的getFloatFrequencyData,取得每个频点的电平。在当前fftSize = 2048下,频域缓冲区有 1024 个值;数组下标需要按下标 × 采样率 / FFT 长度换算成 Hz,不能直接当作频率。
SpectrumCanvas.vue先读取频谱,再生成绘图点。下面节选这两部分,省略缓冲区准备、网格和描边;rate是实际采样率,maxHz取 5000 Hz 与采样率一半中的较小值,几个换算函数位于core/analysis.ts:
analyser.getFloatFrequencyData(buffer)constdbToY=(db:number):number=>y+dbToRatio(db,MIN_DB,MAX_DB)*heightconstn=buffer.lengthconstpoints:Array<{px:number;py:number}>=[]for(leti=0;i<n;i+=1){constfreq=binToFrequency(i,rate,props.fftSize||n*2)if(freq>maxHz)breakconstvalue=buffer[i]constdb=Number.isFinite(value)?value:MIN_DBpoints.push({px:x+frequencyToRatio(freq,0,maxHz)*width,py:dbToY(db),})}横坐标先由binToFrequency算出这个点代表多少 Hz,再由frequencyToRatio把它放到 0–5000 Hz 的显示范围中。例如 440 Hz 约在横轴宽度的 8.8% 处,1320 Hz 约在 26.4% 处。遍历到显示范围之外就结束。
纵轴刻度下限MIN_DB为 -100,上限MAX_DB为 -10。dbToRatio用(MAX_DB - db) / (MAX_DB - MIN_DB)求出从顶部向下的比例,再限制到 0–1;信号越强,数值越接近上限,点就越靠上。静音时可能读到-Infinity,这里把非有限值放到下限,避免无效坐标进入 Canvas。
频谱峰值还受 FFT 分辨率限制。本次采样率为 48000 Hz,相邻频点间隔为48000 / 2048 = 23.4375 Hz。初版检查中,振荡器设为 440 Hz,最高频谱采样点却在 445.3125 Hz,正好是下标为 19 的频点。这是分析分辨率造成的差异。判断首次播放是否用了错误参数时,要看实际振荡器频率,不能要求频谱峰值数字始终等于输入值。
图里的橙色虚线另有来源:harmonicFrequencies按当前基频的整数倍生成参考位置,例如 440、880、1320 Hz,再用同样的横坐标换算画虚线。它们帮助对照谐波位置,蓝色曲线才来自分析数据;正弦波图里出现这些虚线,并不表示每个位置都有同样强的成分。
两张图各自通过useRafLoop在requestAnimationFrame回调里读取数据、重画。Web Audio 持续生成声音,画图循环读取当时的分析结果;组件卸载时取消动画回调并释放资源。
在自己的电脑上试一遍
本文对应本地验证通过的修复版。完整代码同步到文首 AtomGit 仓库后,可以用下面的命令启动;本次验证环境是 Node.js v22.21.0:
gitclone https://atomgit.com/qq_40202349/sound-lab.gitcdsound-labnpmcinpmrun dev打开终端给出的本地地址。仓库根目录就是应用目录,不需要再进入app。这里提供的是源码与本地运行方式,仓库页面本身不是在线演示站点。
第一次试,可以按下面这个顺序,把三个实验和这次修复一起走一遍:
- 刷新页面,先选“比较音高”,把音量调低,再点击播放。此时使用的是 220 Hz;把它改到 440 Hz,听音高变化,看波形变密。
- 保持 440 Hz,先播放正弦波,再切换三角波和方波。观察主峰右侧增加了哪些峰,听同一个基频下的区别。
- 切回正弦波,把音量从低到高缓慢调整。波形应主要改变高度,周期基本不变;最后调到 0,声音消失,时域曲线回到零附近。
- 点停止,选中频率数字框里的全部数字,逐字输入
660,按 Enter。数字框和滑杆都应显示 660,页面保持停止,直到再次点击播放。
需要检查或构建时,运行:
npmtestnpmrun typechecknpmrun build这个版本面向桌面浏览器,使用单一振荡器,频谱只展示 0–5000 Hz。它适合对比声音的基本变化,不是环境噪声测量工具:页面的 dBFS 表示数字信号电平,不能据此判断耳机或外放的实际声压。代码采用 MIT 协议。
这次用码道的体会
声音实验室的应用代码和后续修复,都是由码道完成的。首版里已经有 Vue 工程、Web Audio 音频引擎、两张 Canvas 图和三个实验入口,控件也接上了声音与图形更新。这替我省去了搭建工程、编写音频与绘图模块、再把它们连起来的工作。拿到首版后,就能直接调参数、听声音,检查实验有没有达到预期。
码道还把实验要求落实成了具体的技术处理。比如,比较音量时要能看出波形高低变化,需求里因此要求固定纵轴。码道在绘图代码中把幅度范围固定为 ±1,又把分析器接在增益节点后,读取经过音量调整的真实采样,声音和波形高度才能一起变化。启停时的平滑过渡,也由它写进了音频引擎。需求描述的是希望听到、看到的现象,支撑这些现象的节点连接、数据读取和坐标计算,则由码道完成。
首版的问题通过独立复核发现后,复现步骤和原因定位一起交给码道。它继续在原工程中修改:首次播放改为读取当前参数,数字输入拆成草稿和提交两个阶段,图表单位另起一行,并补上相应的回归测试。这一轮码道承担了按反馈修改代码、补写测试的工作。修复报告还列出了改动文件和验证结果,拿到交付后,可以沿着原来的操作再次检查,确认反馈是否落实。
对这次项目而言,码道承担了工程实现和反馈后的修补,人工精力可以更多放在实验设计与验收上。首版漏掉的播放顺序和数字输入问题,说明上手检查仍然必要。能把明确的实验需求做成可运行的页面,又能在同一个工程里接着改,是这次实际用下来最有帮助的地方。