同一套聽音樂的 DSP,先長在一副 576×288 的眼鏡上,再搬進瀏覽器變成一個能印 PDF 的歌譜工具。 這篇記錄什麼可以整段搬、什麼一定要重寫,兩個只有真的跑起來才會現形的坑, 以及後來把「即時」與「離線」同時放進同一支工具之後學到的事。
歌譜聽寫沒有寫任何新的訊號處理。它整段沿用 Ear Notes 的 src/dsp/,
那套東西在眼鏡上被實機除錯過好幾輪,數字都是量出來的不是猜的。
| 模組 | 做什麼 | 關鍵取捨 |
|---|---|---|
chroma.ts | 頻譜摺成 12 個音高類別 | 視窗 4096 點而非 2048:16 kHz 下 2048 的頻率解析度是 7.8 Hz,但 E2→F2 只差 4.9 Hz,分不開 |
chords.ts | 和弦模板比對 + 遲滯追蹤 | 泛音權重折進模板,而不是從訊號裡扣掉;連續三幀同答案才對外更新 |
key.ts | Krumhansl-Schmuckler 判調 | 加一個「主音投票器」修正純輪廓比對常見的關係大小調互換 |
yin.ts | 單音基頻 | 自相關族,對人聲與獨奏樂器比 FFT 峰值穩 |
onset.ts | SuperFlux 起音 → 拍長 | 相對門檻 0.15、最小間隔 128 ms(刷弦一下最短就這麼長) |
leadsheet.ts | 和弦時間軸、小節線 | 補 256 ms 的偵測延遲;支持度不到 0.75 就不畫小節線 |
這是整個移植裡唯一真正的結構性差異,其他都是排版。
眼鏡(即時) 網頁(離線)
───────────────── ─────────────────
邊聽邊猜 先聽完,再一次判對
猜錯了要當場改 沒有「講太早」這種事
調號變了 → 重算整段譜 拿最終調號算一次就好
8 秒門檻:聽不夠久 8 秒門檻:多餘,而且有害
就先別亂講
Ear Notes 裡有一個叫 retranscribe() 的函式,專門處理「調號變了,
之前算的級數全部要重算」。它的註解寫著一段實機踩到的畫面:
表頭已經寫1=G、譜面卻還是照 C 算出來的5522(G 大調的小星星應該是1155)。表頭與譜面對不起來,比兩個都還沒好更糟。
離線版根本不需要這個函式——先把整首餵完、拿到最終調號,再算一次旋律級數就好。 一個在即時架構下必須存在的補救機制,換個執行模型之後直接消失。
需求原話是「能及時轉錄,最後再重新判讀」。拆開來是兩件事: 錄音當下就把和弦與音高推上畫面,讓人看得到它在跟著動;停止之後拿整段波形重跑一次, 用更準的結果覆蓋掉即時那版。
程式碼那一半是簡單的。兩條路共用同一個 Pipeline,
差別只有串流餵與整段重跑:
錄音中 ──→ LiveTranscriber.feed(4096 樣本一塊) ──→ 暫定的和弦/音高/調號
│
└─ 同一個 Pipeline / ChordTracker / KeyDecider / YIN
│
停止後 ──→ analyze(整段波形) ──→ 定稿
即時結果本來就會跳——它看不到未來,聽到新的證據就會改答案。 這在演算法上完全正確,在畫面上卻是災難:使用者不知道哪一版是暫定的, 只會覺得「這個工具很不穩」。
那句差異描述是刻意加的。使用者看到答案自己變了,要能分辨這是 重判修正了它,而不是工具在亂跳。差異本身就是這個功能的賣點,藏起來反而可惜。
坑二(下一節)講的是「KeyDecider 的 8 秒門檻在離線分析裡只剩傷害」。
0.2.0 之後,這個結論的另一半也成立了——同一個門檻在即時那一側是必要的保護。
兩個執行模型現在活在同一支工具裡,同一段程式碼被兩個呼叫端用出相反的正當性:
8 秒的錄音:
即時側 累積到 7.8 秒 → 判不出調號 ✓ 正確(它真的還沒聽夠)
重判側 整段跑完 → C 大調 ✓ 正確(它已經聽完了)
這兩行同時是對的。所以測試不是去斷言「兩版應該一致」,而是斷言它們該不一致的時候真的不一致——
這正是重判存在的理由,也是 test/live.test.ts 裡釘住的其中一條。
ScriptProcessorNode,程式碼要求緩衝 4096——但那個數字
是瀏覽器可以自己決定的。萬一它給 16384,就會原地踩進坑一:
安靜地丟掉大半資料,畫面上只是「和弦怪怪的」。LiveTranscriber.feed() 一律自己照 4096 切開再送進 Pipeline,
呼叫端不必知道這件事。測試直接釘住:餵 16384 與餵 4096,幀數與和弦序列必須完全相同。
即時兩條管線(chroma 和弦 + YIN 音高)同時開,實測成本 9 ms/秒訊號,
離「跟不上即時」的 1000 還很遠——所以 ScriptProcessorNode 雖然已被棄用,
現階段還沒有換 AudioWorklet 的理由(換了要多一個模組檔,單檔工具就不再是單檔)。
兩個都通過 TypeScript、通過 code review,只有寫了端到端測試才抓到。
Pipeline 內部是一個環形緩衝,大小 max(chromaSize×4, 16384) = 16384 樣本,
約 1 秒。它是為「麥克風每次來一小塊」設計的。離線分析很自然會想一次餵大塊——
chunk= 1024 frames=122 dropped=0 和弦=C G Am F ✓
chunk= 4096 frames=122 dropped=0 和弦=C G Am F ✓
chunk= 8192 frames=122 dropped=0 和弦=C G Am F ✓
chunk=16384 frames= 32 dropped=12 和弦=C F ✗
餵 16384 只跑了 32 幀、丟掉 12 塊,四個和弦只認出兩個。 它不會報錯——畫面上看起來只是「和弦怪怪的」, 而「和弦怪怪的」在一個和弦辨識工具裡是最難察覺的失敗模式,因為你本來就預期它偶爾會錯。
KeyDecider 有一個 8 秒門檻:累積不到 8 秒就回 null,不亂講。
在眼鏡上這是對的。
但一個 8.0 秒的音檔,chroma 幀從第一個完整視窗才開始,累積到 7.8 秒—— 差 0.2 秒,整首歌完全判不出調號。而離線分析在那一行已經把整首聽完了, 沒有「講太早」的風險,這個門檻只剩副作用。
修法是收尾時直接再判一次、不過門檻。 教訓不是「門檻設錯了」,是一個機制的正當性來自它的執行模型,換了模型要重新問一次它還成不成立。
分析結果不等於譜。調號、和弦、音高、拍長全部算對了, 畫出來仍然可能是一張樂手不會想用的東西——差別在記號。
原本分小節的寫法是「這個音放不下就整個推到下一小節」。四四拍的小節裡因此塞進五拍, 而樂手數一下就發現了——這種錯比判錯一個和弦嚴重,因為它讓整張譜失去可信度。
正確做法是把跨過小節線的音切開再用連結線接回去:
第 3 拍起的四拍音變成「這小節 2 拍 ⌒ 下小節 2 拍」。連結線畫成 SVG 弧線而不是用
⌒ / ‿ 字元——眼鏡那邊整理過缺字表,知道靠字型碰運氣會變成豆腐方塊。
切音本來就會在音跟音之間留空隙——起音偵測靠它才切得開重複音。 那個空隙不是休止符。所以門檻定在 0.4 拍:低於它一律當成正常斷句,不寫進譜裡。
還有一條容易寫錯的:休止符被小節線切開時不接連結線。 連結線的意思是「這兩個音是同一個音、繼續響」,休止沒有東西在響, 切開就只是兩個休止。這條寫進單元測試裡了,因為它是會被「順手加上去」的那種錯。
其餘的記號都是排版細節,但每一項都影響「像不像譜」:減時線(八分一條、十六分兩條)、
附點、延長線、八度點。這些一律直排疊在數字的正上方與正下方,
不能用 <sup> / <sub>——那會把點推到右上角,
跟減時線對不齊,也跟隔壁的音對不齊。
| 搬得動 | 搬不動 | |
|---|---|---|
| 訊號處理 | 整個 src/dsp/ 一行沒改。純運算、零外部依賴,這是它搬得動的唯一原因 |
— |
| 資料結構 | JianpuNote / Bar 抽成 notation.ts 就能共用 |
原本它們住在 jianpu.ts 裡,跟全形數字、576px 格子綁在一起 |
| 版面 | — | 眼鏡那套是像素級的固定寬度排版:getTextWidth 量測、
CJK 20px/ASCII 11.3px、缺字整行消失。網頁用 HTML 排版,這些全部不適用 |
| 樂理慣例 | 首調參考點(小調以關係大調為 1)必須一致,否則同一份譜上下兩行會互相矛盾 | — |
src/dsp/ 當初刻意不碰 bridge、不碰 DOM,是為了讓測試能在 node 裡跑。
這個決定的真正回報出現在兩年後的另一個平台上:整個資料夾複製過去就能用。
把和弦往上移 N 個半音是小學算術,難的是移完之後怎麼拼。
A# 而不是 Bb,是明顯的錯譜——看得懂,但會皺眉Gb 一樣糟所以拼法要跟著目標調走:慣例用降記號的大調(F、B♭、E♭、A♭、D♭、G♭)用一組拼法, 其餘用升記號。小調則看它的關係大調,跟譜面上的調號記號一致。
1 2 3 是相對主音的級數,不是絕對音高。
所以整首移調時,變的只有和弦名與表頭那行 1 = X,
旋律那一列一個字都不用改。這是首調記法本來就有的性質,
剛好讓轉調功能幾乎免費。
原始需求是「詞譜和弦都寫上去」。詞這一項我沒做自動辨識,理由不是做不到,是做出來不值得端上來:
所以做的是「貼上整首歌詞 → 依序放進小節 → 不對再自己拖」。 這解決的是實際的痛點,而且不會產生一份看起來像真的、其實錯得離譜的歌詞。
I'm positive(確定/正電雙關)寫壞成 I'm sure of my electrons,
還編造了一個假的諧音解釋。在學習類工具裡,
一個自信的錯誤比一個誠實的空白傷害大得多。歌詞辨識是同一種取捨。
三支 node 測試 + 一支真瀏覽器 QA,全部跑得起來:
# 1. 端到端:合成 C–G–Am–F → 整首離線分析 → 比對調號與和弦
node test/analyze.test.ts → 端到端 PASS
# 2. 即時/重判:串流餵同一段波形
node test/live.test.ts
即時和弦:C → G → Am → F
畫面更新 47 次 · 幀 184/747 · 丟棄 0 · 即時成本 9 ms/秒
餵 16384:和弦 C → G → Am → F · 丟棄 0 ← 跟餵 4096 完全一致
8 秒:即時 判不出來 → 重判 C 大調(即時累積 7.8/8 秒)
→ 即時/重判 PASS
# 3. 記譜規則:小節拍數、連結線、休止符
node test/notation.test.ts
跨小節長音 : 1/1 2/1 3/2[start] | 3/2[end] 4/1 5/1
補休止符 : 1/1 2/1 3/1 0/1 | 0/1 4/1 5/1 6/1 | 7/1
→ 記譜 PASS
真瀏覽器那支走五段,其中錄音是真的走過麥克風那條路——
用 Chrome 的 --use-file-for-fake-audio-capture 把 WAV 灌進
getUserMedia,不是模擬呼叫:
node scripts/qa.mjs
A 丟檔案 調號 C 大調 · 和弦 C|G|Am|F · 轉 D 後 D|A|Bm|G · 歌詞四句都進譜
B 麥克風 0.8s [暫定] 和弦=C 調=還在聽…(1/8 秒)
7.9s [暫定] 和弦=F 調=還在聽…(8/8 秒)
9.0s [暫定] 和弦=C 調=C 大調 ← 過門檻,跳出暫定調號
停止 → [已被取代] 重判完成:調號跟即時一樣(C 大調)· 和弦 7 段 → 7 段
C 單音 即時音高 C4 G4 A4 G4 F4 E4 D4 C4 → 有調號後變 C4 · 1
定稿簡譜 1 1 5 5 | 6 6 5— | 4 4 3 3 | 2 2 1— | 1 1 ← 小星星
D 刷弦 拍號 4/4 · 小節編號 1,5 · 終止線 1 個 · 和弦 C|G|Am|F|C|G|Am|F
E 節奏 減時線 5 · 附點 1 · 延長線 3 · 連結線 1 · 休止符 1
1 2 3 4 5· 6 | 5— 0 3 | 1—— 2 | 2 3 4 5
主控台錯誤 : 無 → QA PASS
前面那兩個坑,就是第一層測試抓到的。在寫測試之前,這支工具「編譯過、看起來會動、 實際上四個和弦只認得出兩個、而且完全判不出調號」。 0.2.0 新加的那條「餵大塊也要一樣」同理——它擋的是一個還沒發生但隨時會發生的瀏覽器行為。