Skip to content

G722编码和解码 ​

一、背景 ​

在前面的几轮演进中,当前项目已经完成了从 SIP 建链、SDP 协商、RTP 收发,到 MediaProcessor 媒体处理抽象,再到 Realtime 模型桥接的一整套基础能力建设。整体架构已经具备“电话接入 → 音频处理 → 模型交互 → RTP 回发”的完整闭环。

不过,在 codec 支持层面,系统此前主要围绕 PCMU 和 PCMA 两种 G.711 窄带语音展开。这种实现足以打通链路,也足以承载基础语音回环和实时语音机器人能力,但当系统开始面向更高质量的电话语音场景时,仅支持 G.711 就显得不够了。

实际电话环境中,许多终端、网关或 SBC 在 SDP Offer 中会同时携带:

  • G722
  • PCMU
  • PCMA
  • telephone-event

其中,G722 是电话场景中非常常见的一种宽带语音 codec。相比传统 8k 的 G.711 窄带语音,G.722 在媒体处理层面对应的是 16k PCM,这意味着更宽的语音频带、更好的听感,以及与后续 ASR、TTS、Realtime LLM 语音模型之间更低的格式转换损耗。

因此,当系统已经具备媒体处理和实时模型桥接能力之后,继续停留在“只支持 G.711”的状态,会带来两个明显问题:

第一,电话侧本身只能维持在窄带质量; 第二,为了满足模型输入输出要求,链路中会产生额外的重采样开销。

例如,Realtime 模型通常要求:

  • 输入:16k PCM
  • 输出:24k PCM

如果当前 SIP 会话协商的是 G.711,那么整条链路就会变成:

text
8k -> 16k -> Realtime Model -> 24k -> 8k

而当 SIP 会话能够优先协商为 G.722 时,链路可以自然变成:

text
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 一致,例如:

  • createEncoder
  • createDecoder
  • encodeDirect
  • decodeDirect
  • destroyEncoder
  • destroyDecoder

因此本次接入的思路并不是“重新发明一个 G722 codec”,而是直接把现成的编解码能力纳入到现有 RTP 媒体链路中。

2. 为什么 G722Codec.sampleRate() 返回 16000 ​

这里有一个很容易混淆、但必须明确说明的点。

在 SDP / RTP 描述中,G722 常见写法是:

text
a=rtpmap:9 G722/8000

这里的 8000 表示的是 RTP clock rate,不是媒体处理层真正使用的 PCM 采样率。

对媒体处理链来说,G722 对应的实际 PCM 采样率应按 16000 来理解。也就是说:

  • CodecSpec.clockRate = 8000
  • G722Codec.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 实现如下:

java
package nexus.io.sip.rtp.codec;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;

import nexus.io.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 资源

示例代码如下:

java
package nexus.io.sip.model;

import nexus.io.sip.rtp.RtpUdpServer;
import nexus.io.sip.rtp.codec.AudioCodec;
import nexus.io.sip.rtp.codec.AudioResampler;
import nexus.io.sip.rtp.codec.NegotiatedAudioFormatResolver;
import nexus.io.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

至于:

  • inputResampler
  • outputResampler
  • rtpResampler

这类重采样器如何在 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 媒体平台真正具备宽带语音处理能力。

MIT Licensed