[{"data":1,"prerenderedAt":744},["ShallowReactive",2],{"category-music":3},[4,453],{"id":5,"title":6,"body":7,"category":438,"date":439,"description":440,"draft":441,"extension":442,"meta":443,"navigation":444,"path":445,"seo":446,"stem":447,"tags":448,"__hash__":452},"blog\u002Fblog\u002Fweb-piano.md","网页钢琴与 Web Audio 合成",{"type":8,"value":9,"toc":428},"minimark",[10,14,37,41,46,58,263,267,278,286,305,309,320,324,389,396,399,411,414,417,424],[11,12,6],"h1",{"id":13},"网页钢琴与-web-audio-合成",[15,16,17,18,22,23,28,29,32,33,36],"p",{},"Web Audio 不只能分析已有音频——用 ",[19,20,21],"strong",{},"振荡器（Oscillator）"," 也能从零合成声音。下面 demo 实现了一个 3 个八度的网页钢琴：按住琴键持续发声，松手渐弱；空格键控制延音踏板；还可点击内置乐谱自动演奏。若你读过 ",[24,25,27],"a",{"href":26},"\u002Fblog\u002Faudio-visualizer","Web Audio 音乐可视化","，那篇讲的是 ",[19,30,31],{},"AnalyserNode 分析播放","，本篇则走 ",[19,34,35],{},"合成发声"," 路线。",[38,39,40],"h2",{"id":40},"核心链路",[42,43,45],"h3",{"id":44},"_1-音高音符名-频率","1. 音高：音符名 → 频率",[15,47,48,49,53,54,57],{},"每个琴键对应如 ",[50,51,52],"code",{},"C4","、",[50,55,56],{},"F#5"," 的音符名。通过 MIDI 公式换算频率（A4 = 440Hz）：",[59,60,65],"pre",{"className":61,"code":62,"language":63,"meta":64,"style":64},"language-javascript shiki shiki-themes github-light github-dark","function noteToFreq(note) {\n  const match = note.match(\u002F^([A-G]#?)(\\d)$\u002F)\n  const [, name, octaveStr] = match\n  const midi = (Number(octaveStr) + 1) * 12 + NOTE_INDEX[name]\n  return 440 * Math.pow(2, (midi - 69) \u002F 12)\n}\n","javascript","",[50,66,67,91,148,174,217,257],{"__ignoreMap":64},[68,69,72,76,80,84,88],"span",{"class":70,"line":71},"line",1,[68,73,75],{"class":74},"szBVR","function",[68,77,79],{"class":78},"sScJk"," noteToFreq",[68,81,83],{"class":82},"sVt8B","(",[68,85,87],{"class":86},"s4XuR","note",[68,89,90],{"class":82},") {\n",[68,92,94,97,101,104,107,110,112,116,119,122,125,128,131,134,137,140,143,145],{"class":70,"line":93},2,[68,95,96],{"class":74},"  const",[68,98,100],{"class":99},"sj4cs"," match",[68,102,103],{"class":74}," =",[68,105,106],{"class":82}," note.",[68,108,109],{"class":78},"match",[68,111,83],{"class":82},[68,113,115],{"class":114},"sZZnC","\u002F",[68,117,118],{"class":74},"^",[68,120,83],{"class":121},"sA_wV",[68,123,124],{"class":99},"[A-G]",[68,126,127],{"class":121},"#",[68,129,130],{"class":74},"?",[68,132,133],{"class":121},")(",[68,135,136],{"class":99},"\\d",[68,138,139],{"class":121},")",[68,141,142],{"class":74},"$",[68,144,115],{"class":114},[68,146,147],{"class":82},")\n",[68,149,151,153,156,159,162,165,168,171],{"class":70,"line":150},3,[68,152,96],{"class":74},[68,154,155],{"class":82}," [, ",[68,157,158],{"class":99},"name",[68,160,161],{"class":82},", ",[68,163,164],{"class":99},"octaveStr",[68,166,167],{"class":82},"] ",[68,169,170],{"class":74},"=",[68,172,173],{"class":82}," match\n",[68,175,177,179,182,184,187,190,193,196,199,202,205,208,211,214],{"class":70,"line":176},4,[68,178,96],{"class":74},[68,180,181],{"class":99}," midi",[68,183,103],{"class":74},[68,185,186],{"class":82}," (",[68,188,189],{"class":78},"Number",[68,191,192],{"class":82},"(octaveStr) ",[68,194,195],{"class":74},"+",[68,197,198],{"class":99}," 1",[68,200,201],{"class":82},") ",[68,203,204],{"class":74},"*",[68,206,207],{"class":99}," 12",[68,209,210],{"class":74}," +",[68,212,213],{"class":99}," NOTE_INDEX",[68,215,216],{"class":82},"[name]\n",[68,218,220,223,226,229,232,235,237,240,243,246,249,251,253,255],{"class":70,"line":219},5,[68,221,222],{"class":74},"  return",[68,224,225],{"class":99}," 440",[68,227,228],{"class":74}," *",[68,230,231],{"class":82}," Math.",[68,233,234],{"class":78},"pow",[68,236,83],{"class":82},[68,238,239],{"class":99},"2",[68,241,242],{"class":82},", (midi ",[68,244,245],{"class":74},"-",[68,247,248],{"class":99}," 69",[68,250,201],{"class":82},[68,252,115],{"class":74},[68,254,207],{"class":99},[68,256,147],{"class":82},[68,258,260],{"class":70,"line":259},6,[68,261,262],{"class":82},"}\n",[42,264,266],{"id":265},"_2-发声振荡器-adsr-包络","2. 发声：振荡器 + ADSR 包络",[15,268,269,270,273,274,277],{},"每次按键创建一个 ",[50,271,272],{},"OscillatorNode","（三角波）和 ",[50,275,276],{},"GainNode","：",[59,279,284],{"className":280,"code":282,"language":283},[281],"language-text","Oscillator → GainNode → destination\n","text",[50,285,282],{"__ignoreMap":64},[15,287,288,289,292,293,296,297,300,301,304],{},"按下时 ",[50,290,291],{},"gain"," 从极小值指数爬升到 ",[50,294,295],{},"PEAK_GAIN","（attack ≈ 20ms）；松手时指数衰减到 0 再 ",[50,298,299],{},"stop"," 振荡器。多音同奏用 ",[50,302,303],{},"Map\u003Cnote, voice>"," 管理，每个音符独立一条链路。",[42,306,308],{"id":307},"_3-延音踏板","3. 延音踏板",[15,310,311,312,315,316,319],{},"踩下踏板（空格或点击踏板按钮）时，松开的键不会立刻 ",[50,313,314],{},"releaseVoice","，声音会保持到抬踏板。抬踏板后，所有 ",[50,317,318],{},"keyHeld === false"," 的 voice 才进入释放包络。",[42,321,323],{"id":322},"_4-三路输入","4. 三路输入",[325,326,327,340],"table",{},[328,329,330],"thead",{},[331,332,333,337],"tr",{},[334,335,336],"th",{},"输入",[334,338,339],{},"处理方式",[341,342,343,366,377],"tbody",{},[331,344,345,349],{},[346,347,348],"td",{},"鼠标 \u002F 触摸",[346,350,351,354,355,358,359,362,363],{},[50,352,353],{},"pointerdown"," 捕获 → ",[50,356,357],{},"pressNote","；",[50,360,361],{},"pointerup"," → ",[50,364,365],{},"releaseNote",[331,367,368,371],{},[346,369,370],{},"物理键盘",[346,372,373,376],{},[50,374,375],{},"KEY_MAP"," 映射 QWERTY 键到音符",[331,378,379,382],{},[346,380,381],{},"内置乐谱",[346,383,384,385,388],{},"按 BPM 计算节拍时长，",[50,386,387],{},"playNoteTimed"," 定时发声",[15,390,391,392,395],{},"乐谱播放用 ",[50,393,394],{},"playToken"," 取消机制：切换曲目或点停止时递增 token，异步循环检测到 token 变化即退出。",[38,397,398],{"id":398},"在线体验",[15,400,401,402,405,406,410],{},"点击或键盘演奏（需用户手势后 ",[50,403,404],{},"AudioContext"," 才会 resume）。也可 ",[24,407,409],{"href":408},"\u002Fdemos\u002Fweb-piano","全屏预览","。",[412,413],"web-piano-demo",{},[38,415,416],{"id":416},"小结",[15,418,419,420,423],{},"网页钢琴的核心是 ",[19,421,422],{},"振荡器合成 + 包络塑形 + 多 voice 管理","。和音乐可视化的 AnalyserNode 不同，这里完全在「生成」声音。真实钢琴音色需要更复杂的采样或物理建模，但三角波 + 简单 ADSR 已足够演示 Web Audio 的合成路径。想继续深入，可以试试换波形、加滤波器，或接入 SoundFont 采样。",[425,426,427],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sA_wV, html code.shiki .sA_wV{--shiki-default:#032F62;--shiki-dark:#DBEDFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":64,"searchDepth":150,"depth":150,"links":429},[430,436,437],{"id":40,"depth":93,"text":40,"children":431},[432,433,434,435],{"id":44,"depth":150,"text":45},{"id":265,"depth":150,"text":266},{"id":307,"depth":150,"text":308},{"id":322,"depth":150,"text":323},{"id":398,"depth":93,"text":398},{"id":416,"depth":93,"text":416},"music","2026-06-20","用振荡器与 GainNode 包络合成钢琴音，支持键盘演奏、延音踏板与内置乐谱自动播放。",false,"md",{},true,"\u002Fblog\u002Fweb-piano",{"title":6,"description":440},"blog\u002Fweb-piano",[449,450,451],"web-audio","piano","synthesis","X7pBoWNSCuyPLsw20K-xCjEh7X0xvqwlB3lQ2I-Xk4o",{"id":454,"title":27,"body":455,"category":438,"date":734,"description":735,"draft":441,"extension":442,"meta":736,"navigation":444,"path":26,"seo":737,"stem":738,"tags":739,"__hash__":743},"blog\u002Fblog\u002Faudio-visualizer.md",{"type":8,"value":456,"toc":725},[457,460,479,481,503,507,515,521,534,569,584,588,597,640,643,646,650,673,675,685,688,690,703,713,722],[11,458,27],{"id":459},"web-audio-音乐可视化",[15,461,462,463,466,467,470,471,474,475,478],{},"浏览器里的音乐播放器不只是 ",[50,464,465],{},"\u003Caudio>"," 控件。通过 ",[19,468,469],{},"Web Audio API"," 把音频流接入 ",[50,472,473],{},"AnalyserNode","，再用 ",[19,476,477],{},"Canvas"," 读取频谱数据，就能做出随节拍律动的可视化。下方 demo 支持四种频谱样式、LRC 歌词滚动与歌单切换。",[38,480,40],{"id":40},[15,482,483,484,487,488,490,491,494,495,498,499,502],{},"整个 demo 可以拆成三条并行管线：",[19,485,486],{},"音频图","负责把 ",[50,489,465],{}," 接入 Web Audio；",[19,492,493],{},"Canvas 动画","每帧读取分析器数据并绘制；",[19,496,497],{},"歌词","根据 ",[50,500,501],{},"currentTime"," 滚动高亮。",[42,504,506],{"id":505},"_1-音频图从元素到-analysernode","1. 音频图：从元素到 AnalyserNode",[15,508,509,511,512,514],{},[50,510,465],{}," 本身只负责解码和播放。第一次点击播放时，才创建 ",[50,513,404],{}," 并把元素挂到音频图上：",[59,516,519],{"className":517,"code":518,"language":283},[281],"\u003Caudio> → MediaElementSource → AnalyserNode → destination（扬声器）\n                              ↓\n                    每帧读取频谱 \u002F 波形数据\n",[50,520,518],{"__ignoreMap":64},[15,522,523,525,526,529,530,533],{},[50,524,473],{}," 是可视化的数据来源。",[50,527,528],{},"fftSize"," 设为 256（128 个频点），",[50,531,532],{},"smoothingTimeConstant"," 控制相邻帧的平滑程度——数值越大，柱状跳动越「黏」，越小则越灵敏。",[59,535,537],{"className":61,"code":536,"language":63,"meta":64,"style":64},"audioCtx = new AudioContext()\nsource = audioCtx.createMediaElementSource(audio)\nanalyser = audioCtx.createAnalyser()\nanalyser.fftSize = 256\nsource.connect(analyser)\nanalyser.connect(audioCtx.destination)\n",[50,538,539,544,549,554,559,564],{"__ignoreMap":64},[68,540,541],{"class":70,"line":71},[68,542,543],{},"audioCtx = new AudioContext()\n",[68,545,546],{"class":70,"line":93},[68,547,548],{},"source = audioCtx.createMediaElementSource(audio)\n",[68,550,551],{"class":70,"line":150},[68,552,553],{},"analyser = audioCtx.createAnalyser()\n",[68,555,556],{"class":70,"line":176},[68,557,558],{},"analyser.fftSize = 256\n",[68,560,561],{"class":70,"line":219},[68,562,563],{},"source.connect(analyser)\n",[68,565,566],{"class":70,"line":259},[68,567,568],{},"analyser.connect(audioCtx.destination)\n",[15,570,571,572,575,576,579,580,583],{},"浏览器要求 ",[19,573,574],{},"用户手势"," 后才能 ",[50,577,578],{},"resume()"," 音频上下文，所以 demo 必须点播放才能启动；暂停时取消 ",[50,581,582],{},"requestAnimationFrame","，Canvas 回到 idle 态。",[42,585,587],{"id":586},"_2-canvas-绘制同一数据源四种画法","2. Canvas 绘制：同一数据源，四种画法",[15,589,590,591,593,594,596],{},"绘制循环在 ",[50,592,582],{}," 里跑。根据当前模式，从 ",[50,595,473],{}," 取不同数据：",[325,598,599,612],{},[328,600,601],{},[331,602,603,606,609],{},[334,604,605],{},"模式",[334,607,608],{},"读取方法",[334,610,611],{},"绘制思路",[341,613,614,627],{},[331,615,616,619,624],{},[346,617,618],{},"圆形 \u002F 柱状 \u002F 粒子",[346,620,621],{},[50,622,623],{},"getByteFrequencyData",[346,625,626],{},"128 个频点映射为半径、柱高或粒子位移",[331,628,629,632,637],{},[346,630,631],{},"波形",[346,633,634],{},[50,635,636],{},"getByteTimeDomainData",[346,638,639],{},"时域采样连成曲线，并做帧间平滑",[15,641,642],{},"频谱类模式用频率能量驱动视觉；波形模式看的是振幅随时间的变化。切歌或切模式时会清空帧间缓存，避免上一首的能量「残留」。",[15,644,645],{},"每首歌还配了一套主题色（主色、辅色、光晕），切歌时同步更新 Canvas 渐变和 UI 变量。",[42,647,649],{"id":648},"_3-歌词同步","3. 歌词同步",[15,651,652,653,656,657,660,661,664,665,668,669,672],{},"LRC 文本先解析成 ",[50,654,655],{},"{ time, text }[]","，播放过程中根据 ",[50,658,659],{},"audio.currentTime"," 二分查找当前行，用 ",[50,662,663],{},"transform: translateY(...)"," 把高亮行滚到视口中央。",[50,666,667],{},"timeupdate"," 事件触发频繁，滚动逻辑做了 500ms 节流；点击某行歌词则 ",[50,670,671],{},"seek"," 到对应时间。",[38,674,398],{"id":398},[15,676,677,678,680,681,684],{},"下方为完整交互 demo（需点击播放后启动 ",[50,679,404],{},"）。也可 ",[24,682,409],{"href":683},"\u002Fdemos\u002Faudio-viz"," 打开独立页面。",[686,687],"audio-visualizer-demo",{},[38,689,416],{"id":416},[15,691,692,693,702],{},"Web Audio 可视化并不复杂：",[19,694,695,696,698,699,701],{},"把 ",[50,697,465],{}," 接入 ",[50,700,473],{},"，再在 Canvas 里每帧读数据、画图形","。难点在于体验细节——播放前后的 idle 动画、切歌时的状态重置、波形与频谱不同的平滑策略，以及歌词与进度的双向联动。",[15,704,705,706,709,710,712],{},"四种频谱样式共用同一套分析器，只是映射方式不同；这也是前端可视化里常见的模式：",[19,707,708],{},"数据层稳定，表现层可插拔","。如果你要做自己的播放器可视化，可以从一个 ",[50,711,473],{}," + 一种绘制函数开始，再逐步加模式和主题。",[15,714,715,716,719,720,410],{},"想走另一条 Web Audio 路线——用振荡器",[19,717,718],{},"合成","声音而非分析播放——可以看 ",[24,721,6],{"href":445},[425,723,724],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":64,"searchDepth":150,"depth":150,"links":726},[727,732,733],{"id":40,"depth":93,"text":40,"children":728},[729,730,731],{"id":505,"depth":150,"text":506},{"id":586,"depth":150,"text":587},{"id":648,"depth":150,"text":649},{"id":398,"depth":93,"text":398},{"id":416,"depth":93,"text":416},"2026-05-18","用 Canvas 与 Web Audio API 实现圆形、柱状、波形、粒子四种频谱，并同步 LRC 歌词。",{},{"title":27,"description":735},"blog\u002Faudio-visualizer",[449,740,741,742],"canvas","visualization","vue","ghCDv_tcwtPfmSAefPGprSYdKOWg6tvA3kUQQMnVylU",1782309644795]