G722编码和解码
一、背景
在前面的几轮演进中,当前项目已经完成了从 SIP 建链、SDP 协商、RTP 收发,到 MediaProcessor 媒体处理抽象,再到 Realtime 模型桥接的一整套基础能力建设。整体架构已经具备“电话接入 → 音频处理 → 模型交互 → RTP 回发”的完整闭环。
不过,在 codec 支持层面,系统此前主要围绕 PCMU 和 PCMA 两种 G.711 窄带语音展开。这种实现足以打通链路,也足以承载基础语音回环和实时语音机器人能力,但当系统开始面向更高质量的电话语音场景时,仅支持 G.711 就显得不够了。
实际电话环境中,许多终端、网关或 SBC 在 SDP Offer 中会同时携带:
G722PCMUPCMAtelephone-event
其中,G722 是电话场景中非常常见的一种宽带语音 codec。相比传统 8k 的 G.711 窄带语音,G.722 在媒体处理层面对应的是 16k PCM,这意味着更宽的语音频带、更好的听感,以及与后续 ASR、TTS、Realtime LLM 语音模型之间更低的格式转换损耗。
因此,当系统已经具备媒体处理和实时模型桥接能力之后,继续停留在“只支持 G.711”的状态,会带来两个明显问题:
第一,电话侧本身只能维持在窄带质量; 第二,为了满足模型输入输出要求,链路中会产生额外的重采样开销。
例如,Realtime 模型通常要求:
- 输入:
16k PCM - 输出:
24k PCM
如果当前 SIP 会话协商的是 G.711,那么整条链路就会变成:
8k -> 16k -> Realtime Model -> 24k -> 8k
而当 SIP 会话能够优先协商为 G.722 时,链路可以自然变成:
16k -> Realtime Model -> 24k -> 16k
这不仅减少了无谓的升采样和降采样,也能够明显提升电话侧的最终语音质量。
基于这个背景,本篇的目标就是把 G722 真正接入到现有媒体架构中,让系统在 codec 层面具备“优先宽带、自动回退”的能力。
二、目标
本次改造的核心目标不是简单“新增一个 G722 类”,而是要把 G722 作为当前媒体体系中的一个正式成员接入进来,使它能够和现有的 SIP、SDP、RTP 以及 MediaProcessor 架构自然配合。
本篇的目标主要有三点。
1. 为 RTP 层补齐 G722 编解码能力
在现有 PCMU / PCMA 之外,增加 G722Codec,使 RTP 层能够根据当前会话协商结果,正确完成:
- G722 RTP payload → PCM16
- PCM16 → G722 RTP payload
这意味着 RTP 层不再局限于 G.711,而是真正具备按协商 codec 动态收发音频的能力。
2. 将 G722 的实现接入统一 AudioCodec 抽象
为了保持媒体层结构稳定,本次接入不改变既有的 AudioCodec 设计,而是让 G722Codec 继续实现统一接口,与 PcmuCodec、PcmaCodec 保持一致。这样 RTP 层无需关心底层 codec 是纯 Java 实现还是 JNI 实现,只需要按统一接口调用 encode / decode 即可。
3. 基于现有开源库提供 G722 的实际编码能力
本篇中的 G722 编解码,不再停留在占位类或伪实现,而是直接基于笔者维护的 JNI 音频编解码库:
java-media-codec
来完成接入。这样做的好处是:
- 不需要在 SIP 项目里重复实现 G722 算法本体
- Java 层可以继续保持统一接口
- 底层编解码由 JNI 调用 C 实现完成
- 可以复用已有的 DirectByteBuffer、zero-copy、低延迟设计
三、设计说明
1. 为什么使用 java-media-codec
G722 本身属于标准语音 codec,实现层面既涉及 bitstream,又涉及状态机和内部滤波历史。如果在当前 SIP 项目中重新从零实现一套完整 G722 算法,不但工作量较大,也会带来额外的维护成本。
而 java-media-codec 已经把这部分能力以统一 JNI 接口的方式封装好,对 Java 层暴露的使用方式与其他 codec 一致,例如:
createEncodercreateDecoderencodeDirectdecodeDirectdestroyEncoderdestroyDecoder
因此本次接入的思路并不是“重新发明一个 G722 codec”,而是直接把现成的编解码能力纳入到现有 RTP 媒体链路中。
2. 为什么 G722Codec.sampleRate() 返回 16000
这里有一个很容易混淆、但必须明确说明的点。
在 SDP / RTP 描述中,G722 常见写法是:
a=rtpmap:9 G722/8000
这里的 8000 表示的是 RTP clock rate,不是媒体处理层真正使用的 PCM 采样率。
对媒体处理链来说,G722 对应的实际 PCM 采样率应按 16000 来理解。也就是说:
CodecSpec.clockRate = 8000G722Codec.sampleRate() = 16000
这两个值同时存在,并不冲突,只是分别服务于不同层次:
- RTP timestamp 计算使用
clockRate - 音频处理、重采样、模型输入输出使用
PCM sampleRate
这也是本次接入中必须特别强调的设计点。
3. 为什么一个 G722Codec 实例只对应一个媒体流/会话
G722 编码器和解码器在 native 层通常都包含内部状态,因此它们并不是简单的“无状态工具函数”。为了避免多通话之间共享状态带来的串音、错位或线程安全问题,本次实现采用的原则是:
- 一个
G722Codec实例对应一个会话 - encoder / decoder 在实例内部懒创建
- encode / decode 分别加锁,避免并发访问同一个 native handle
- 会话结束时统一释放资源
这也与当前项目里“运行时媒体资源绑定到 CallSession 生命周期”的方向一致。
四、实现方式
本次 G722 接入使用的是笔者开源库:
https://github.com/litongjava/java-media-codec
Java 层 G722Codec 实现如下:
package com.litongjava.sip.rtp.codec;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import com.litongjava.media.MediaCodec;
/**
* 基于 java-media-codec JNI 的 G.722 codec。
*
* 设计说明:
* 1. 一个 G722Codec 实例对应一个媒体流/会话,不要跨多个通话共享。
* 2. encoder / decoder 都是有状态的 native 对象。
* 3. encode / decode 分别加锁,避免同一实例并发访问同一个 native handle。
* 4. 内部使用 DirectByteBuffer,满足 JNI zero-copy 要求。
*/
public class G722Codec implements AudioCodec, AutoCloseable {
private static final int CODEC_TYPE = MediaCodec.CODEC_G722;
private static final int SAMPLE_RATE = 16000;
private static final int CHANNELS = 1;
private static final int BITRATE = 64000;
private static final int OPTIONS = 0;
private final Object encodeLock = new Object();
private final Object decodeLock = new Object();
private long encoder;
private long decoder;
private ByteBuffer encodePcmBuffer;
private ByteBuffer encodeOutBuffer;
private ByteBuffer decodeInBuffer;
private ByteBuffer decodePcmBuffer;
@Override
public String codecName() {
return "G722";
}
@Override
public int payloadType() {
return 9;
}
/**
* RTP/SDP 中 G722 常写为 G722/8000,
* 这里返回媒体处理层使用的 PCM 采样率 16000。
*/
@Override
public int sampleRate() {
return SAMPLE_RATE;
}
@Override
public short[] decode(byte[] payload) {
if (payload == null || payload.length == 0) {
return new short[0];
}
synchronized (decodeLock) {
ensureDecoder();
int encodedLen = payload.length;
int pcmSamplesCapacity = estimateDecodedSamples(encodedLen);
int pcmBytesCapacity = pcmSamplesCapacity * 2;
decodeInBuffer = ensureDirectBuffer(decodeInBuffer, encodedLen, ByteOrder.BIG_ENDIAN);
decodePcmBuffer = ensureDirectBuffer(decodePcmBuffer, pcmBytesCapacity, ByteOrder.LITTLE_ENDIAN);
decodeInBuffer.clear();
decodePcmBuffer.clear();
decodeInBuffer.put(payload);
int decodedSamples = MediaCodec.decodeDirect(decoder, decodeInBuffer, encodedLen, decodePcmBuffer);
if (decodedSamples < 0) {
throw new IllegalStateException("G722 decodeDirect failed, code=" + decodedSamples);
}
short[] out = new short[decodedSamples];
for (int i = 0; i < decodedSamples; i++) {
out[i] = decodePcmBuffer.getShort(i * 2);
}
return out;
}
}
@Override
public byte[] encode(short[] pcm16) {
if (pcm16 == null || pcm16.length == 0) {
return new byte[0];
}
synchronized (encodeLock) {
ensureEncoder();
int pcmSamples = pcm16.length;
int pcmBytes = pcmSamples * 2;
int encodedCapacity = estimateEncodedBytes(pcmSamples);
encodePcmBuffer = ensureDirectBuffer(encodePcmBuffer, pcmBytes, ByteOrder.LITTLE_ENDIAN);
encodeOutBuffer = ensureDirectBuffer(encodeOutBuffer, encodedCapacity, ByteOrder.BIG_ENDIAN);
encodePcmBuffer.clear();
encodeOutBuffer.clear();
for (int i = 0; i < pcmSamples; i++) {
encodePcmBuffer.putShort(i * 2, pcm16[i]);
}
int encodedLen = MediaCodec.encodeDirect(encoder, encodePcmBuffer, pcmSamples, encodeOutBuffer);
if (encodedLen < 0) {
throw new IllegalStateException("G722 encodeDirect failed, code=" + encodedLen);
}
byte[] out = new byte[encodedLen];
for (int i = 0; i < encodedLen; i++) {
out[i] = encodeOutBuffer.get(i);
}
return out;
}
}
private void ensureEncoder() {
if (encoder != 0) {
return;
}
encoder = MediaCodec.createEncoder(CODEC_TYPE, SAMPLE_RATE, CHANNELS, BITRATE, OPTIONS);
if (encoder == 0) {
throw new IllegalStateException("Failed to create G722 encoder");
}
}
private void ensureDecoder() {
if (decoder != 0) {
return;
}
decoder = MediaCodec.createDecoder(CODEC_TYPE, SAMPLE_RATE, CHANNELS, BITRATE, OPTIONS);
if (decoder == 0) {
throw new IllegalStateException("Failed to create G722 decoder");
}
}
private int estimateEncodedBytes(int pcmSamples) {
long bytes = ((long) pcmSamples * BITRATE + (SAMPLE_RATE * 8L - 1)) / (SAMPLE_RATE * 8L);
return (int) Math.max(bytes + 16, 64);
}
private int estimateDecodedSamples(int encodedBytes) {
long samples = ((long) encodedBytes * 8L * SAMPLE_RATE + BITRATE - 1) / BITRATE;
return (int) Math.max(samples + 16, 160);
}
private static ByteBuffer ensureDirectBuffer(ByteBuffer buffer, int capacity, ByteOrder order) {
if (buffer != null && buffer.capacity() >= capacity) {
buffer.clear();
buffer.order(order);
return buffer;
}
return ByteBuffer.allocateDirect(capacity).order(order);
}
@Override
public void close() {
synchronized (encodeLock) {
if (encoder != 0) {
MediaCodec.destroyEncoder(encoder);
encoder = 0;
}
encodePcmBuffer = null;
encodeOutBuffer = null;
}
synchronized (decodeLock) {
if (decoder != 0) {
MediaCodec.destroyDecoder(decoder);
decoder = 0;
}
decodeInBuffer = null;
decodePcmBuffer = null;
}
}
}
五、绑定到 CallSession
为了避免在多个模块中重复创建和管理 codec 实例,本次实现将运行时 codec 资源绑定到 CallSession。
这意味着:
- 一个通话会话只持有一个运行时
AudioCodec - 对于 G722,这个
AudioCodec实际上就是G722Codec - 会话结束时由
CallSession统一释放 codec 资源
示例代码如下:
package com.litongjava.sip.model;
import com.litongjava.sip.rtp.RtpUdpServer;
import com.litongjava.sip.rtp.codec.AudioCodec;
import com.litongjava.sip.rtp.codec.AudioResampler;
import com.litongjava.sip.rtp.codec.NegotiatedAudioFormatResolver;
import com.litongjava.sip.sdp.CodecSpec;
public class CallSession {
private int pcmSampleRate;
private int channels = 1;
private String callId;
private String fromTag;
private String toTag;
private String transport;
private String remoteSipIp;
private int remoteSipPort;
private String remoteRtpIp;
private int remoteRtpPort;
private int localRtpPort;
private long createdTime;
private long updatedTime;
private long ackDeadline;
private boolean ackReceived;
private boolean terminated;
private String last200Ok;
private RtpUdpServer rtpServer;
private CodecSpec selectedCodec;
private AudioResampler inputResampler;
private AudioResampler outputResampler;
private AudioResampler rtpResampler;
private boolean telephoneEventSupported;
private int remoteTelephoneEventPayloadType = -1;
private int ptime = 20;
/**
* 一个 session 一个运行时 codec 实例。
* 对于 JNI codec,避免多个会话共享同一个 native 状态对象。
*/
private AudioCodec audioCodec;
private long localSsrc = System.nanoTime() & 0xFFFFFFFFL;
private int sendSequence = 0;
private long sendTimestamp = 0;
private boolean rtpInitialized = false;
public synchronized int nextSendSequence() {
sendSequence = (sendSequence + 1) & 0xFFFF;
return sendSequence;
}
public synchronized long nextSendTimestamp(int pcmSampleCount) {
int step = toRtpTimestampStep(pcmSampleCount);
if (step <= 0) {
step = pcmSampleCount > 0 ? pcmSampleCount : 160;
}
if (!rtpInitialized) {
rtpInitialized = true;
sendTimestamp = step & 0xFFFFFFFFL;
return sendTimestamp;
}
sendTimestamp = (sendTimestamp + step) & 0xFFFFFFFFL;
return sendTimestamp;
}
private int toRtpTimestampStep(int pcmSampleCount) {
if (pcmSampleCount <= 0) {
return 0;
}
CodecSpec codec = this.selectedCodec;
int clockRate = codec != null && codec.getClockRate() > 0 ? codec.getClockRate() : 8000;
int pcmSampleRate = NegotiatedAudioFormatResolver.resolveSessionPcmSampleRate(codec);
if (pcmSampleRate <= 0) {
pcmSampleRate = clockRate > 0 ? clockRate : 8000;
}
long step = ((long) pcmSampleCount * clockRate) / pcmSampleRate;
if (step <= 0) {
step = 1;
}
return (int) step;
}
public synchronized AudioCodec getAudioCodec() {
return audioCodec;
}
public synchronized void setAudioCodec(AudioCodec audioCodec) {
this.audioCodec = audioCodec;
}
public synchronized void release() {
if (audioCodec instanceof AutoCloseable) {
try {
((AutoCloseable) audioCodec).close();
} catch (Exception e) {
// ignore
}
}
audioCodec = null;
if (inputResampler != null) {
try {
inputResampler.close();
} catch (Exception ignore) {
}
inputResampler = null;
}
if (outputResampler != null) {
try {
outputResampler.close();
} catch (Exception ignore) {
}
outputResampler = null;
}
if (rtpResampler != null) {
try {
rtpResampler.close();
} catch (Exception ignore) {
}
rtpResampler = null;
}
}
// ... 其余 getter / setter 省略
}
六、这一篇的边界
需要说明的是,本篇重点聚焦于:
- 为什么要引入 G722
- 如何基于
java-media-codec提供 G722 编解码能力 - 如何把 G722 codec 作为运行时媒体资源绑定到
CallSession
至于:
inputResampleroutputResamplerrtpResampler
这类重采样器如何在 RealtimeMediaProcessor、SipRealtimeSession、RtpUdpHandler 中分别使用,属于下一篇更适合展开的话题。因为那部分关注的是“会话级重采样资源如何参与上下行链路”,而不是 G722 编解码本身。
换句话说,这一篇解决的是:
如何让系统具备真正的 G722 宽带语音编解码能力。
而下一篇更适合继续讨论:
如何围绕会话采样率、模型采样率和 RTP 编码采样率,组织一套清晰的重采样策略。
七、总结
在现有 SIP + RTP + Realtime 媒体架构已经基本成型的前提下,G722 的接入是一次非常关键的升级。
它的意义不只是“多支持一个 codec”,而是让系统真正从固定的窄带电话链路,迈向可协商的宽带语音链路。这样一来:
- 当对端支持 G722 时,可以优先协商到更高质量的电话音频
- RTP 层可以直接按 16k PCM 进入媒体处理链
- 与 Realtime 模型之间的音频格式转换更加自然
- 后续继续扩展更多 codec 时,也有了统一的实现模式
本次实现基于 java-media-codec,把 G722 的底层编解码能力通过 JNI 暴露给 Java,再通过 G722Codec 接入现有 AudioCodec 抽象,最终绑定到 CallSession 生命周期中统一管理。这使得整个实现既保持了接口层的一致性,也复用了已有的 native 音频能力。
可以把这一篇的核心价值概括成一句话:
G722 的接入,不只是补齐一个 codec,而是让当前 SIP 媒体平台真正具备宽带语音处理能力。
