這篇是自己的備忘錄。目的是讓我每次忘記的時候,能快速想起「電話從手機打出來,到 AI 說話回應」這整條路上,封包到底怎麼走、在哪裡被處理。


# 整體架構一覽

手機撥打


[中華電信 PSTN]
│  傳統語音電話

[IPPBX 192.168.1.210]   ← aiphone-backend 事先 SIP REGISTER 進來
│  SIP INVITE + RTP (UDP)

[aiphone-backend 192.168.8.223:13167]

├─► SIP 訊號處理:180 Ringing → 200 OK → ACK


[RTP 讀取迴圈]  ← goroutine AlwaysRecordAudioToBuffer
│  每 20ms 一包 μ-law,160 bytes

├──────────────────────────────┐
▼                              ▼
[ASR Jitter Buffer]       [dual_buffer 錄音]
│  平滑輸出(排序+補靜音)      │  存完整通話音訊
│  每 20ms 吐一包              ▼
▼                         [WAV → MinIO]
[Vosk WebSocket ASR]
│  Final text

[MCP / LLM]
│  streaming text

[ElevenLabs TTS]
│  μ-law audio

[RTP 發送回 IPPBX]


[手機聽到 AI 回應]

# 第一段:手機 → 中華電信 PSTN → IPPBX

# 手機撥打

使用者撥打一組台灣市話或 0800 號碼,進入中華電信 PSTN(Public Switched Telephone Network)。

PSTN 是傳統電話網路,信號走電路交換(Circuit Switching)。中華電信這端拿到號碼,查路由,轉到對應的 SIP Trunk 或 FXO 接口。

# PSTN → IPPBX(SIP Trunk)

IPPBX(192.168.1.210)是一台 IP 電話交換機(比如 FreeSWITCH、Asterisk)。它對外透過 SIP Trunk 跟中華電信連線,接收打進來的電話。

PSTN 語音在這裡轉換成 IP 封包

  • 訊號層:SIP(Session Initiation Protocol),走 UDP 5060
  • 媒體層:RTP(Real-time Transport Protocol),走 UDP 動態 port(通常 10000–20000)

IPPBX 根據撥入的號碼,決定把這通電話路由到哪個分機或 SIP endpoint。


# 第二段:IPPBX → aiphone-backend(SIP 訊號)

SIP Methods 的詳細說明(REGISTER、INVITE、180 Ringing、200 OK、ACK、BYE 每個步驟)在本系列第一篇已經講過,這裡只記這個架構裡各元件的角色。

SIP Protocol 教學(一):SIP Methods 是什麼?

aiphone-backend 啟動時 主動向 IPPBX 送 REGISTER,讓 IPPBX 知道這個分機在哪:

Contact: <sip:aiphone@192.168.8.223:13167>

來電時,IPPBX 送 INVITE(帶 SDP,指定 RTP port 和 codec PCMU),OnInvite()180 Ringing200 OK(帶自己的 SDP)→ 收到 ACK

通話建立後 SIP 就退場,接下來全靠 RTP 傳語音。


# 第三段:RTP 語音傳輸

# RTP 封包結構

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|V=2|P|X|  CC   |M|     PT      |       Sequence Number         |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           Timestamp                           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|           Synchronization Source (SSRC) identifier           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                         Payload (μ-law)                       |
|                        160 bytes / 20ms                       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

重點欄位:

  • Sequence Number(16-bit):封包序號,0–65535 循環。用來排序、偵測遺失封包。
  • Timestamp:以 sampling rate 為單位(G.711 = 8000 Hz,所以每 20ms 加 160)。
  • PT(Payload Type):0 = PCMU(G.711 μ-law)
  • Payload:實際語音資料,每包 160 bytes = 20ms 音訊

# μ-law(PCMU)是什麼?

G.711 μ-law 是電話標準編碼格式:

  • Sample rate:8000 Hz(每秒 8000 個取樣點)
  • Bit depth:8-bit per sample(但用對數壓縮,不是線性 PCM)
  • 每包 20ms → 160 samples → 160 bytes
  • 靜音特殊值:0x7F(μ-law 的靜音位元組)

# 第四段:aiphone-backend 內部處理

# AlwaysRecordAudioToBuffer(goroutine)

一個持續跑的 goroutine,不斷從 UDP socket 讀取 RTP 封包。

for {
    n, err := conn.Read(buf)
    packet := parseRTP(buf[:n])  // 解析 RTP header + payload
    // 送到兩個地方
    jitterBuffer.Push(packet)    // ASR 用
    dualBuffer.Write(packet)     // 錄音用
}

封包進來後,同時走兩條路。


# 路徑一:ASR Jitter Buffer → Vosk ASR

# 為什麼需要 Jitter Buffer?

網路封包不會完美每隔 20ms 到達一次:

理想:  |--20ms--|--20ms--|--20ms--|--20ms--|
實際:  |--5ms--|--------50ms--------|--15ms--|--30ms--|

如果直接把這種不規律的封包丟給 Vosk ASR,ASR 會因為「太久沒收到音訊」而誤判句子結束,切出錯誤的辨識結果。

# Jitter Buffer 運作方式

RTP 封包進來(不規律)  →  [seq 排序 min-heap]  →  固定每 20ms 吐一包  →  Vosk ASR

核心機制:

  • 進來:每個封包 push 進 min-heap(按 Sequence Number 排序)
  • 吐出:一個固定頻率的 ticker,每 20ms 從 heap 頂端 pop 一包
    • heap 有封包 → 吐出該包
    • heap 空了(underrun)→ 送一包 0x7F μ-law 靜音,避免 ASR 誤判句尾
    • 封包太舊(已過時)→ 丟掉

# min-heap 實作(Go)

container/heap 標準庫,自己實作排序邏輯:

type asrPacketHeap []*sip_rtp.Packet

func (h asrPacketHeap) Less(i, j int) bool {
    seqI := h[i].SequenceNumber
    seqJ := h[j].SequenceNumber
    diff := int32(seqI) - int32(seqJ)
    // 處理 uint16 溢位(65535 → 0)
    if diff > 30000 { return false }  // seqJ 其實比 seqI 新(繞圈了)
    if diff < -30000 { return true }  // seqI 其實比 seqJ 新(繞圈了)
    return seqI < seqJ               // 正常:小的 seq 排前面
}

為什麼要處理 diff > 30000

Sequence Number 是 uint16,最大 65535,之後會從 0 重新開始:

... 65533, 65534, 65535, 0, 1, 2 ...

在數字上 0 < 65535 是對的,但時間上 0 其實比 65535 還新。
diff > 30000 偵測「繞一圈」的情況,確保順序永遠正確。

# Adaptive Delay(自適應延遲)

Buffer 不是固定大小,會根據網路狀況動態調整:

情況 調整
預設 180ms(9 包)
連續 3 次 underrun +20ms(最大 400ms)
連續 50 次正常 -10ms(最小 120ms)

手機直接通話時常會觸發 underrun,delay 會自動往上調。


# 路徑二:dual_buffer → WAV → MinIO

同一份 RTP 音訊也會被另一個 goroutine 錄製:

  • 累積成完整的 WAV 檔案
  • 通話結束後上傳到 MinIO(物件儲存)
  • 用途:事後聽取、ASR 重跑、對話記錄

# Vosk ASR

Jitter Buffer 平穩輸出的音訊,透過 WebSocket 送給 Vosk(本地 ASR server):

aiphone-backend ──WebSocket──► Vosk server
  每 20ms 送 160 bytes μ-law    │
                                │ partial result(說話中)
                                │ final result(偵測到停頓)
                               ◄─────────────────────────

Vosk 回傳 final text 時,代表這句話辨識完成,交給下一層。


# MCP / LLM

辨識出的文字送給 LLM(透過 MCP 協定或直接 API)。

LLM 以 streaming 方式回傳 token,邊生成邊往下送給 TTS,降低整體延遲。


# ElevenLabs TTS

LLM 的文字 → ElevenLabs 合成語音 → 回傳 μ-law 格式的音訊。

(也可能是其他 TTS,但格式最終要轉成 G.711 μ-law,才能透過 RTP 送回去。)


# 第五段:AI 回應 → RTP → 手機

合成好的 μ-law 音訊,打包成 RTP 封包,從 aiphone-backend 送回 IPPBX:

aiphone-backend ──RTP (UDP)──► IPPBX ──► 手機

封包格式跟接收時一樣:

  • PT = 0(PCMU)
  • 每包 160 bytes / 20ms
  • Sequence Number 自己維護(從 0 開始遞增)
  • Timestamp 每包加 160

IPPBX 收到後,轉送到手機那端,手機就聽到 AI 的回應聲音。


# 掛斷流程(BYE)

通話結束時,任一方送出 BYE

aiphone-backend → IPPBX
BYE sip:aiphone@... SIP/2.0

IPPBX → aiphone-backend
SIP/2.0 200 OK

aiphone-backend 收到 BYE 後:

  1. 停止 RTP 讀取 goroutine
  2. 停止 Jitter Buffer 吐出
  3. 把錄音的 dual_buffer 存成 WAV 上傳 MinIO
  4. 清理這通通話的所有狀態

# 完整時序圖

手機       PSTN      IPPBX      aiphone-backend     Vosk    LLM    TTS
 │          │          │               │               │      │      │
 │─撥號────►│          │               │               │      │      │
 │          │─SIP路由─►│               │               │      │      │
 │          │          │─INVITE───────►│               │      │      │
 │          │          │◄──180 Ringing─│               │      │      │
 │          │          │◄──200 OK──────│               │      │      │
 │          │          │──ACK─────────►│               │      │      │
 │          │          │               │               │      │      │
 │          │   ◄══════RTP(μ-law)══════│               │      │      │  手機說話
 │          │          │               │──WS 音訊─────►│      │      │
 │          │          │               │◄─final text───│      │      │
 │          │          │               │──text─────────────►  │      │
 │          │          │               │◄──streaming tokens───│      │
 │          │          │               │──text─────────────────────► │
 │          │          │               │◄──μ-law audio──────────────│
 │          │   ══════RTP(μ-law)══════►│               │      │      │  AI 回應
 │◄─語音────│          │               │               │      │      │

# 快速記憶卡

協定 傳輸 內容
訊號 SIP UDP 5060 INVITE / 200 OK / BYE
媒體 RTP UDP 動態 port μ-law 音訊,每包 20ms / 160 bytes
語音辨識 WebSocket TCP 音訊 → 文字(Vosk)
LLM HTTP/MCP TCP 文字 → 文字(streaming)
TTS HTTP TCP 文字 → μ-law 音訊

SIP 負責「建立/終止通話」,RTP 負責「傳語音」,兩者完全分開。


最後更新:2026-03-11

請我喝[茶]~( ̄▽ ̄)~*

Young 微信支付

微信支付

Young 支付寶

支付寶