知識報告 · 眼鏡工具堆疊

從眼鏡搬到瀏覽器

同一套聽音樂的 DSP,先長在一副 576×288 的眼鏡上,再搬進瀏覽器變成一個能印 PDF 的歌譜工具。 這篇記錄什麼可以整段搬、什麼一定要重寫,兩個只有真的跑起來才會現形的坑, 以及後來把「即時」與「離線」同時放進同一支工具之後學到的事。

2026-09-06 / Ear Notes 0.6.0 → Chord Ear 0.6.0 → 歌譜聽寫 0.2.0

一、這支站在什麼上面

歌譜聽寫沒有寫任何新的訊號處理。它整段沿用 Ear Notes 的 src/dsp/, 那套東西在眼鏡上被實機除錯過好幾輪,數字都是量出來的不是猜的。

模組做什麼關鍵取捨
chroma.ts頻譜摺成 12 個音高類別視窗 4096 點而非 2048:16 kHz 下 2048 的頻率解析度是 7.8 Hz,但 E2→F2 只差 4.9 Hz,分不開
chords.ts和弦模板比對 + 遲滯追蹤泛音權重折進模板,而不是從訊號裡扣掉;連續三幀同答案才對外更新
key.tsKrumhansl-Schmuckler 判調加一個「主音投票器」修正純輪廓比對常見的關係大小調互換
yin.ts單音基頻自相關族,對人聲與獨奏樂器比 FFT 峰值穩
onset.tsSuperFlux 起音 → 拍長相對門檻 0.15、最小間隔 128 ms(刷弦一下最短就這麼長)
leadsheet.ts和弦時間軸、小節線補 256 ms 的偵測延遲;支持度不到 0.75 就不畫小節線
那 256 ms 是算出來的,不是調出來的。
chroma 視窗 4096 點(256 ms)的時間戳記在視窗尾端 → 中心差 128 ms; 追蹤器要連續三幀同一答案才更新 → 又多 2 × hop(64 ms) = 128 ms。合起來 256 ms。 不補這一段,小節線會整體往後偏(實測 downbeat 誤差 0.24–0.38 秒,16 組裡 5 組因此判錯)。

二、即時與離線是兩種不同的問題

這是整個移植裡唯一真正的結構性差異,其他都是排版。

眼鏡(即時)          網頁(離線)
─────────────────    ─────────────────
邊聽邊猜              先聽完,再一次判對
猜錯了要當場改        沒有「講太早」這種事
調號變了 → 重算整段譜  拿最終調號算一次就好
8 秒門檻:聽不夠久     8 秒門檻:多餘,而且有害
  就先別亂講

Ear Notes 裡有一個叫 retranscribe() 的函式,專門處理「調號變了, 之前算的級數全部要重算」。它的註解寫著一段實機踩到的畫面:

表頭已經寫 1=G、譜面卻還是照 C 算出來的 5522 (G 大調的小星星應該是 1155)。表頭與譜面對不起來,比兩個都還沒好更糟。

離線版根本不需要這個函式——先把整首餵完、拿到最終調號,再算一次旋律級數就好。 一個在即時架構下必須存在的補救機制,換個執行模型之後直接消失。

這一節後來被自己推翻了一半。
上面這個對照寫的時候,「即時」在眼鏡上、「離線」在瀏覽器上,一邊一個。 0.2.0 把兩種放進同一支工具:錄音當下邊聽邊出,停止之後整段重判。 於是那些原本用來區分兩個平台的差異,變成同一個畫面上兩個並存的答案—— 這才是真正麻煩的地方,下一節講。

三、兩段式:邊聽邊出 → 收尾重判

需求原話是「能及時轉錄,最後再重新判讀」。拆開來是兩件事: 錄音當下就把和弦與音高推上畫面,讓人看得到它在跟著動;停止之後拿整段波形重跑一次, 用更準的結果覆蓋掉即時那版。

程式碼那一半是簡單的。兩條路共用同一個 Pipeline, 差別只有串流餵與整段重跑:

錄音中  ──→ LiveTranscriber.feed(4096 樣本一塊)  ──→ 暫定的和弦/音高/調號
                    │
                    └─ 同一個 Pipeline / ChordTracker / KeyDecider / YIN
                    │
停止後  ──→ analyze(整段波形)                    ──→ 定稿

難的是畫面,不是演算法

即時結果本來就會跳——它看不到未來,聽到新的證據就會改答案。 這在演算法上完全正確,在畫面上卻是災難:使用者不知道哪一版是暫定的, 只會覺得「這個工具很不穩」。

整個介面只用一條規則區分兩版:虛線框+琥珀色=暫定,實線框+綠色=定稿。
即時面板從頭到尾掛著「暫定 · 邊聽邊猜,答案會一直改——這不是最後結果」; 重判完成後它變灰、標成「已被取代」,並且直接寫出兩版差在哪: 「重判完成:調號跟即時一樣(C 大調)· 和弦 7 段 → 7 段。以下方定稿為準。」

那句差異描述是刻意加的。使用者看到答案自己變了,要能分辨這是 重判修正了它,而不是工具在亂跳。差異本身就是這個功能的賣點,藏起來反而可惜。

同一段程式碼,兩個呼叫端,正當性相反

坑二(下一節)講的是「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>——那會把點推到右上角, 跟減時線對不齊,也跟隔壁的音對不齊。

合成素材太乾,會讓 DSP 看起來壞掉。
驗小節線需要一段刷弦伴奏。第一版素材衰減給 3.0(乾到只剩敲擊聲), 結果起音被挑出 60 多個(真值 32),拍長跟著偏 7%,小節線整個推不出來。 真吉他是有延音的——把衰減放慢到 1.2 之後,同一套演算法一次就推出 88% 支持度的小節線。
這是同一個教訓的第二次:更早一次是合成器把顫音寫成掃頻, 測出來「顫音讓音高辨識掉到 21%」——是素材壞了,不是 DSP 壞了。

六、什麼搬得動,什麼搬不動

搬得動搬不動
訊號處理 整個 src/dsp/ 一行沒改。純運算、零外部依賴,這是它搬得動的唯一原因 —
資料結構 JianpuNote / Bar 抽成 notation.ts 就能共用 原本它們住在 jianpu.ts 裡,跟全形數字、576px 格子綁在一起
版面 — 眼鏡那套是像素級的固定寬度排版:getTextWidth 量測、 CJK 20px/ASCII 11.3px、缺字整行消失。網頁用 HTML 排版,這些全部不適用
樂理慣例 首調參考點(小調以關係大調為 1)必須一致,否則同一份譜上下兩行會互相矛盾 —
「純運算模組不要碰 I/O」的價值在移植時才兌現。
src/dsp/ 當初刻意不碰 bridge、不碰 DOM,是為了讓測試能在 node 裡跑。 這個決定的真正回報出現在兩年後的另一個平台上:整個資料夾複製過去就能用。

七、轉調不只是加減法

把和弦往上移 N 個半音是小學算術,難的是移完之後怎麼拼。

所以拼法要跟著目標調走:慣例用降記號的大調(F、B♭、E♭、A♭、D♭、G♭)用一組拼法, 其餘用升記號。小調則看它的關係大調,跟譜面上的調號記號一致。

轉調的時候簡譜不用動。
簡譜本來就是首調記法——1 2 3 是相對主音的級數,不是絕對音高。 所以整首移調時,變的只有和弦名與表頭那行 1 = X, 旋律那一列一個字都不用改。這是首調記法本來就有的性質, 剛好讓轉調功能幾乎免費。

八、刻意不做的事:歌詞辨識

原始需求是「詞譜和弦都寫上去」。詞這一項我沒做自動辨識,理由不是做不到,是做出來不值得端上來:

所以做的是「貼上整首歌詞 → 依序放進小節 → 不對再自己拖」。 這解決的是實際的痛點,而且不會產生一份看起來像真的、其實錯得離譜的歌詞。

一個更早的同類判斷。
同一週做的「Wit & Wisdom」眼鏡 app,笑話原本也想用地端模型生。 實測 35B 把 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 新加的那條「餵大塊也要一樣」同理——它擋的是一個還沒發生但隨時會發生的瀏覽器行為。


結論