這篇是自己的備忘錄。目的是讓我每次忘記的時候,能快速想起「電話從手機打出來,到 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 每個步驟)在本系列第一篇已經講過,這裡只記這個架構裡各元件的角色。
aiphone-backend 啟動時 主動向 IPPBX 送 REGISTER,讓 IPPBX 知道這個分機在哪:
Contact: <sip:aiphone@192.168.8.223:13167>
來電時,IPPBX 送 INVITE(帶 SDP,指定 RTP port 和 codec PCMU),OnInvite() 回 180 Ringing → 200 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 後:
- 停止 RTP 讀取 goroutine
- 停止 Jitter Buffer 吐出
- 把錄音的 dual_buffer 存成 WAV 上傳 MinIO
- 清理這通通話的所有狀態
# 完整時序圖
手機 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
