# Bug 說明:TTS 播放時序 Race Condition — 中斷後的「殭屍」sleep 砍掉下一句 ## 關鍵成因 TTS server 產字很快(1~2 秒就生完整句音訊),所以 client 在句子實際播完的前 1~2 秒,就已經把整句的音訊全部排進 Web Audio 的時間軸了。接著 `streamWavBody` 就卡在 `waitForScheduledAudio`,睡到「整句原定結束時間」才醒。 **程式位置**:`src/services/matesxPlayer.ts:556-570` ```typescript private async waitForScheduledAudio(signal) { const waitMs = Math.max(0, (this.nextStartTime - currentTime) * 1000); // 一次算好「整句剩餘長度」 if (waitMs > 0) { await sleep(waitMs); // ← 固定睡這麼久,不理 abort、也不理 resetPlayback 改了 nextStartTime } if (signal?.aborted) { throw new DOMException("TTS playback aborted", "AbortError"); // ← 睡醒才丟 } } ``` ## 完整重現時序(手動中斷情境) 1. **t=0**:句1(假設長 8 秒)開播 → t≈1.5s 整句音訊已排完 → `streamWavBody(1)` 進入 `waitForScheduledAudio`,睡到 t≈8s。 2. **t=3s**:使用者按下中斷 → `cleanupTtsAudio` → `matesxPlayer.stop()` → `resetPlayback` 停掉聲音(聽起來像是停了 ✓)。但 `streamWavBody(1)` 裡的那個 `sleep` 還在跑,要睡到 t≈8s 才會醒——它是一個潛伏中的「殭屍」。 3. **t=6s**:使用者選了下一句,後端回應 → `playTts(句2)` 開播 → 句2 正在播放中。 4. **t=8s**:句1 的 sleep 終於醒了 → 檢查到 `signal.aborted` → 丟出 `AbortError` → 冒到句1 `playTts` 的 outer catch → 呼叫**無守衛**的 `cleanupTtsAudio()`(`EsgVirtualHuman.vue:208`)→ abort 掉「目前」的 ttsController(其實是句2 的)+ `matesxPlayer.stop()` → **句2 在 t≈8s 被憑空砍斷**。 ## 觸發條件 「有可能發生」的關鍵在於:句1 的原定結束時間(也就是殭屍醒來的時刻)**剛好落在句2播放期間**——句2 就會被砍;如果落在句2 開始之前,或落在兩句間的空檔,就不會出事。 因此:**使用者中斷得越早、句子原本越長、殭屍越晚醒**,就越容易砸中正在播的下一句。這是間歇性 / 難重現的 race condition,不是每次都會炸。 ## 兩個缺陷,缺一不可(砍掉任一個就能斷開整條鏈) 1. **`matesxPlayer.ts` 的 `waitForScheduledAudio`(以及裡面的 `sleep`)不即時響應 abort** 被中斷的請求會潛伏到「原句結束時間」才醒過來,而不是中斷當下立刻醒。 2. **`EsgVirtualHuman.vue:208` outer catch 呼叫的 `cleanupTtsAudio()` 少了 `sequence === requestSequence` 的守衛** `finally` 區塊有做這個守衛,但 `catch` 漏掉了——導致遲來的殭屍醒來後,會反手把「當前正在播放」的內容拆掉。 ## 修正建議 - **補上守衛**:在 `catch` 裡加上跟 `finally` 一致的 `sequence === requestSequence` 檢查——過期(stale)的請求一律不准碰共用的播放狀態。 - **讓 abort 立即生效**:把 `waitForScheduledAudio` 改成能被 abort 立即喚醒(例如註冊 abort listener,或改用短輪詢取代單次長 `sleep`),讓殭屍請求一被中斷就死透,不會拖到原定的 8 秒後才反應。 ## 建議的回歸測試 可以直接寫一個很明確的 failing test 來鎖住這個行為: > 模擬「句1(seq1)的 abort 訊號,延遲到句2(seq2)正在播放時才觸發(late-fire)」,斷言此時**不得**打斷句2 目前的播放。