12/18 までで「受信→録音」を押さえ、12/19 でジッタ改善案を固めた。今日は逆側、送信(WAV/PCM → PCMU → RTP) が現状どう組まれているかと、20ms刻み送信の実装をメモする。
全体の送信フロー(現行)
app/tts (WAVファイル)
↓ SessionIn::AppBotAudioFile
session/session.rs: send_wav_as_rtp_pcmu
↓ PCMUフレーム化(20ms相当) + RTPパケット生成
rtp::tx::RtpTxHandle
↓ UDP送信(transport経由)
UAC
送信のキーは「PCMU/8000固定」「20ms刻みでフレーム化」「Seq/Timestamp を増やしつつ RTP で吐く」。
1) WAV→PCMUフレーム化と送信(session)
// src/session/session.rs:502-
async fn send_wav_as_rtp_pcmu(
call_id: &str,
rtp_tx: &RtpTxHandle,
dst_ip: &str,
dst_port: u16,
wav_path: &str,
) -> Result<()> {
let frames = load_wav_as_pcmu_frames(wav_path)?;
let mut seq = 0u16;
let mut ts = 0u32;
for frame in frames {
let pkt = build_rtp_packet(0, seq, ts, 0x1234_5678, frame)?;
rtp_tx.send(dst_ip, dst_port, pkt).await?;
seq = seq.wrapping_add(1);
ts = ts.wrapping_add(160); // 20ms * 8000Hz = 160 samples
tokio::time::sleep(Duration::from_millis(20)).await;
}
Ok(())
}
-
build_rtp_packet(pt=0, seq, ts, ssrc=0x12345678, payload=pcmu)で RTP を作成(rtp/builder.rs)。 - 20msごとに送信(
sleep 20ms)。Timestamp は 160刻み(8000Hz × 0.02s)。 - Seq はラップアラウンド考慮で
wrapping_add。
WAV読み込みと PCMU 変換
// src/session/session.rs:532-
fn load_wav_as_pcmu_frames(path: &str) -> Result<Vec<Vec<u8>>, Error> {
let mut reader = hound::WavReader::open(path)?;
let spec = reader.spec();
if spec.sample_rate != 8000 || spec.channels != 1 {
return Err(anyhow!("WAV must be mono/8k for PCMU"));
}
let mut cur = Vec::new();
let mut frames = Vec::new();
for sample in reader.samples::<i16>() {
let s = sample?;
let mu = linear16_to_mulaw(s);
cur.push(mu);
if cur.len() >= 160 { // 20ms @ 8k
frames.push(cur);
cur = Vec::new();
}
}
if !cur.is_empty() {
frames.push(cur);
}
Ok(frames)
}
- 8k/mono の WAV を前提に 16bit PCM を μ-law へ変換し、160サンプルごとに 1 フレームに分割。
- 余りフレームもそのまま送る(パディングはしない)。
- μ-law変換は
linear16_to_mulaw(同ファイル末尾)。
2) RTPパケット生成(rtp::builder)
// src/rtp/builder.rs:6-
pub fn build_rtp_packet(
payload_type: u8,
sequence_number: u16,
timestamp: u32,
ssrc: u32,
payload: Vec<u8>,
) -> Result<Vec<u8>, Error> {
let mut out = Vec::with_capacity(12 + payload.len());
let b0 = (2 << 6) | (0 << 5) | (0 << 4) | 0; // V=2, P=0, X=0, CC=0
let b1 = payload_type & 0b0111_1111; // M=0, PT=payload_type
out.push(b0);
out.push(b1);
out.extend_from_slice(&sequence_number.to_be_bytes());
out.extend_from_slice(×tamp.to_be_bytes());
out.extend_from_slice(&ssrc.to_be_bytes());
out.extend_from_slice(&payload);
Ok(out)
}
- ヘッダ12バイト固定(拡張/CSRCなし)。Markerは常に0。
- PT は呼び出し元指定(送信時は PCMU=0)。
3) RTP送信ハンドル(rtp::tx::RtpTxHandle)
// src/rtp/tx.rs:11-
#[derive(Clone)]
pub struct RtpTxHandle {
tx: Arc<Mutex<UdpSocket>>,
}
impl RtpTxHandle {
pub fn new() -> Self { ... } // bind(0.0.0.0:0)
pub async fn send(&self, dst_ip: &str, dst_port: u16, pkt: Vec<u8>) -> std::io::Result<()> {
let addr = format!("{}:{}", dst_ip, dst_port);
let sock = self.tx.lock().unwrap();
sock.send_to(&pkt, addr).await?;
Ok(())
}
pub fn stop(&self, _call_id: &str) { /* no-op (将来RTCP等で拡張予定) */ }
}
- 現状はシンプルに
UdpSocket::send_to。ソケットは呼び出し時に共有(Mutex)。 - SSRC/Seq の管理は送信呼び出し側(session)が持つ。
4) 送信トリガー(session内の経路)
-
SessionIn::AppBotAudioFile { path }を受けたらsend_wav_as_rtp_pcmuを非同期で実行(session/session.rs内)。 - 宛先はセッション確立時に握った
peer_rtp_dst()(SDPの c=/m=audio から取得)。 - 送信中フラグ
sending_audioで重複再生を防止。再生終了後に capture_window を開始。
5) 現状の割り切り / 改善余地
- コーデックは PCMU 固定(PT=0)。Marker ビットは常に 0。
- 20ms固定スリープで送るので、Tokio スケジューラの遅延が乗る場合がある(本格的には interval/timer wheel で精度を上げる余地)。
- Seq/TS/SSRC は送信ごとに初期化(長時間/再送考慮はしていない)。
- パケット化は拡張ヘッダ・RTPイベントなし(DTMF/SID 等は未対応)。
まとめ(12/20時点)
- WAV(8k/mono) を μ-law にし、160サンプルごとにフレーム化→RTP化→20msごとに送信。
- RTPパケットは最小ヘッダ(V=2/P=0/X=0/CC=0, M=0, PT=0)。
- 送信ハンドルはシンプルな UDP 直送、Seq/TS は session 側で管理。
- 今後の改善候補: Timer精度向上、SSRC/Seqの継続管理、Marker/SID/DTMF対応、PT可変化(他コーデック対応)。