本文作者:声网Agora 音频算法工程师 赵晓涵。
声网 Agora 在 2019 年 10 月 24 日,正式对所有开发者开源自研的抗丢包音频编解码器 SOLO。该编解码器适用于需要实时音频交互的场景,特别针对弱网对抗进行了优化,并且在相同弱网环境下 MOS 分优于 Opus。SOLO 可应用于各类 RTC 应用,并且可不与 Agora SDK 绑定使用。本文将从源码角度解读 SOLO 的带宽扩展与窄带编码。
SOLO 源码:https://github.com/AgoraIO-Community/Solo
1
带宽扩展
SOLO 在 Silk 的基础上扩展了带宽扩展模块,用来分别处理低频信息(0-8kHz 采样部分)和高频信息(8-16kHz 采样部分),在编码端,两者使用两套耦合的分析编码系统进行码流生成。在解码端,利用低频信号和高频信息,SOLO 可以解码出宽带信号。SOLO 使用带宽扩展主要有两个原因,首先,带宽扩展可以让更多的码率分配到更重要的低频部分,提升编码效率;第二个原因是带宽扩展可以减少进入到信号分析模块的采样点数,从而减少信号分析部分的复杂度(之前需要分析全部的信号,现在只需要分析低频部分)。在减少了原有复杂度的前提下,SOLO 才能够在低频部分额外增加较多计算以选取最佳的多描述编码状态,让编解码音质达到预期。
编码端
SOLO 编码端的大部分操作都是在下述函数中完成的:
代码语言:javascript复制SKP_int32 AGR_Sate_encode_process(
SATEEncCtl *sateCtl, /* I/O SATE Encoder state */
const SKP_int16 *vin, /* I input signal */
NovaBits *bits, /* I bitstream operator */
void *skctrl,
void *hbctrl,
SKP_int16 *nBytesOut /* I encoded bits */
)
首先,输入的16kHz 采样率的语音帧会先进入到一个正交镜像滤波器组(QMF)里进行频带的划分:
代码语言:javascript复制void AGR_Sate_qmf_decomp(
const spx_word16_t *xx, /* I Input signal */
const spx_word16_t *aa, /* I Qmf coefficients */
spx_word16_t *y1, /* O Output low band signal */
spx_word16_t *y2, /* O Output high band signal */
SKP_int32 N, /* I frame size */
SKP_int32 M, /* I Qmf order */
spx_word16_t *mem, /* I/O Qmf state */
SKP_int8 *stack
)
该函数的输出的是两个时域帧,分别包含低频信息和高频信息。低频信息和高频信息会在后续分别进行处理,其中,低频信息会通过函数 SKP_Silk_SDK_Encode 进行分析和编码,这部分内容我们会在稍后的“窄带编码”中进行详细解读。
代码语言:javascript复制SKP_int SKP_Silk_SDK_Encode(
void *encState, /* I/O: State */
const SKP_SILK_SDK_EncControlStruct *encControl, /* I: Control structure */
const SKP_int16 *samplesIn, /* I: Input samples */
SKP_int nSamplesIn, /* I: Number of samples */
SKP_uint8 *outData, /* O: Encoded output */
SKP_int16 *nBytesOut /* I/O: I: Max bytes O:out bytes */
)
高频信息的编码以线性滤波分析为基础,同时为了减少码率,部分依赖于低频信号的残差信息,因此在进行高频信息编码之前,需要通过下述函数提取低频编码信息中的残差信息:
代码语言:javascript复制SKP_int SKP_Silk_SDK_Get_Encoder_Residue( void *encState,SKP_int32 *r )
高频信息的分析和编码在函数 AGR_Bwe_encode_frame_FLP 中进行:
代码语言:javascript复制SKP_int32 AGR_Bwe_encode_frame_FLP(
AGR_Sate_HB_encoder_control_FLP *hbEncCtrl,
AGR_Sate_encoder_hb_state_FLP *psHBEnc,
NovaBits *bits, /* I bitstream operator */
SKP_float *high,
SKP_int32 *residue,
SKP_int16 *nBytesOut /* I/O: Number of bytes in outData (input: Max bytes) */
)
首先高频信息通过 AGR_Sate_find_HB_LPC_FLP 进行分析得到自身的 8 阶 LPC 系数,并将其转化为编码误差较小的 LSP 系数:
代码语言:javascript复制SKP_int32 AGR_Sate_find_HB_LPC_FLP(
AGR_Sate_encoder_hb_state_FLP *psEnc, /* I/O Encoder state FLP */
AGR_Sate_HB_encoder_control_FLP *hbEncCtrl, /* I/O HB Encoder control FLP */
SKP_int32 hb_subfr_length, /* I subframe length */
SKP_int32 hb_lpc_order, /* I high band lpc order */
SKP_int32 first /* I */
)
随后通过 AGR_Sate_lsp_quant_highband 进行双码本量化
代码语言:javascript复制SKP_int32 AGR_Sate_lsp_quant_highband(
SKP_float *lsp, /* I/O lsp coefficients */
SKP_int32 order /* I lpc order */
)
量化后,编码器会将 LSP 系数转化为 1 个 index 来表示:
代码语言:javascript复制idx1 = lsp_weight_quant(qlsp, quant_weight1, AGR_Sate_highband_lsp_cdbk1, HB_LSP_CB1, order);
idx2 = lsp_weight_quant(qlsp, quant_weight2, AGR_Sate_highband_lsp_cdbk2, HB_LSP_CB2, order);
idx = (idx2<<8) idx1;
随后,该帧被分为 4 个子帧,计算各个子帧的残差信号,并计算其对应窄带残差信号子帧的增益,共计4个,使用单码本量化。量化后的 LSP index 和 gain 使用下述函数写入独立码流。
代码语言:javascript复制void AGR_Sate_bits_pack(NovaBits *bits, int data, int nbBits)
其中,LSP index 使用 12 bits 编码,每个子帧 gain 使用 5 bits 编码,所以高频信息的码流共计 12 4*5=32 bits,即 4 bytes,该段码流位于窄带码流之后,和窄带码流中的第二组多描述码流绑定在一起组包。
解码端
解码器可以看做编码器的镜像,解码器收到码流后,首先会通过下述函数解码得到 0-8kHz 采样率的低频信息,这部分我们稍后会做详细解读。
代码语言:javascript复制SKP_int SKP_Silk_SDK_Decode(
void* decState, /* I/O: State */
SKP_SILK_SDK_DecControlStruct* decControl, /* I/O: Control structure */
SKP_int lostFlag, /* I: 0: no loss, 1 loss */
const SKP_uint8 *inData, /* I: Encoded input vector */
const SKP_int16 nBytesIn[], /* I: Number of input Bytes */
SKP_int16 *samplesOut, /* O: Decoded output */
SKP_int16 *nSamplesOut /* I/O: Number of samples */
)
随后,解码器通过下述函数得到低频残差信息以用来解码 8-16kHz 的高频信息。
代码语言:javascript复制SKP_int SKP_Silk_SDK_Get_Decoder_Residue(void *decState, SKP_int32 *r)
同时,解码器会使用以下函数来进行高频信息的解码。
代码语言:javascript复制SKP_int32 AGR_Bwe_decode_frame_FLP(
AGR_Sate_HB_decoder_control_FLP *hbDecCtrl,
AGR_Sate_decoder_hb_state_FLP *psHBDec,
NovaBits *bits, /* I bitstream operator */
SKP_float *OutHigh,
SKP_int32 *residue_Q10,
SKP_int32 lostflag /* I lost falg */
)
该函数内的处理整体上可以分成两种 case,第一种是没有正常接收到包含高频信息的多描述码流,这种情况下会复用上一帧解码出的 LSP index 和子帧增益;如果正常接收到了包含高频信息的多描述码流,则会从 4 bytes 的高频信息中解码、反量化出所需的 LPC 滤波器系数和 4 个子帧增益。
恢复高频信号使用的残差信号是乘上子帧增益后的低频残差信号。使用高频残差再加上高频 LPC 系数,通过以下函数就可以解码得到高频信号。
代码语言:javascript复制void AGR_Sate_LPC_synthesizer(
SKP_float *output, /* O output signal */
SKP_float *ipexc, /* I excitation signal */
SKP_float *sLPC, /* I/O state vector */
SKP_float *a_tmp, /* I filter coefficients */
SKP_int32 LPC_order, /* I filter order */
SKP_int32 subfr_length /* I signal length */
)
随后,低频信息和高频信息会进入到以下函数中,进行高低频的合成,函数输出的是 16kHz 采样率的宽带信号。
代码语言:javascript复制void AGR_Sate_qmf_synth(
const spx_word16_t *x1, /* I Low band signal */
const spx_word16_t *x2, /* I High band signal */
const spx_word16_t *a, /* I Qmf coefficients */
spx_word16_t *y, /* O Synthesised signal */
SKP_int32 N, /* I Signal size */
SKP_int32 M, /* I Qmf order */
spx_word16_t *mem1, /* I/O Qmf low band state */
spx_word16_t *mem2, /* I/O Qmf high band state */
SKP_int8 *stack
)
至此,解码端就完成了将窄带信号扩展成宽带信号的操作。
2
窄带编码
编码模块
SOLO 的窄带编码入口函数是 SKP_Silk_SDK_Encode。
代码语言:javascript复制SKP_int SKP_Silk_SDK_Encode(
void *encState, /* I/O: State */
const SKP_SILK_SDK_EncControlStruct *encControl, /* I: Control structure */
const SKP_int16 *samplesIn, /* I: Input samples */
SKP_int nSamplesIn, /* I: Number of samples */
SKP_uint8 *outData, /* O: Encoded output */
SKP_int16 *nBytesOut /* I/O: I: Max bytes O:out bytes */
)
在该函数内,Solo首先会进行一些带宽检测、重采样(如需)等操作,最终输入到SKP_Silk_encode_frame_FLP 的是8khz采样率的信号
代码语言:javascript复制SKP_int SKP_Silk_encode_frame_FLP(
SKP_Silk_encoder_state_FLP *psEnc, /* I/O Encoder state FLP */
SKP_uint8 *pCode, /* O Payload */
SKP_int16 *pnBytesOut, /* I/O Payload bytes */
/* input: max ; output: used */
const SKP_int16 *pIn /* I Input speech frame */
)
在该函数内,首先通过 SKP_Silk_VAD_FLP 进行信号的静音检测并得到当前信号是语音的概率值,该语音概率值会用来参与控制 LBRR 编码、LSF 转化、噪声整型等模块。
代码语言:javascript复制SKP_int SKP_Silk_VAD_FLP(
SKP_Silk_encoder_state_FLP *psEnc, /* I/O Encoder state FLP */
SKP_Silk_encoder_control_FLP *psEncCtrl, /* I/O Encoder control FLP */
const SKP_int16 *pIn /* I Input signal */
)
接下来的主要步骤有进行长时预测、短时预测、噪声整形、编码等,主要函数及其作用依次为:
SKP_Silk_find_pitch_lags_FLP是用于分析信号的基音周期和清浊音的函数。对于浊音帧,因为周期性较强,所以需要做长时预测(LTP);而对于清音帧,因为周期性不明显,便不需要做长时预测。
代码语言:javascript复制void SKP_Silk_find_pitch_lags_FLP(
SKP_Silk_encoder_state_FLP *psEnc, /* I/O Encoder state FLP */
SKP_Silk_encoder_control_FLP *psEncCtrl, /* I/O Encoder control FLP */
SKP_float res[], /* O Residual */
const SKP_float x[] /* I Speech signal */
)
SKP_Silk_noise_shape_analysis_FLP 是用来进行噪声整形分析的函数,噪声整形可以通过调整量化增益,使得量化噪声随着原始信号能量一起起伏。这样利用掩蔽效应,就难以感知到量化噪声。在这个函数里,除了 Silk 原有的增益控制,SOLO 还有着一套自己的增益计算系统,其逻辑和 Silk 原有增益控制相似,部分参数细节不同。因为在 SOLO 里是双流编码,所以 SOLO 重新进行了码率分配,并根据所分配码率,计算出当前各个码流的理论 SNR。随后,该 SNR 会用于后续增益的计算,该增益用来控制后续处理残差信号时的残差幅值分割比例。
代码语言:javascript复制void SKP_Silk_noise_shape_analysis_FLP(
SKP_Silk_encoder_state_FLP *psEnc, /* I/O Encoder state FLP */
SKP_Silk_encoder_control_FLP *psEncCtrl, /* I/O Encoder control FLP */
const SKP_float *pitch_res, /* I LPC residual */
const SKP_float *x /* I Input signal */
)
SKP_Silk_find_pred_coefs_FLP是进行线性预测的函数,包括短时预测系数和长时预测系数都会在这里被计算出来。其中,LPC 系数会被转化成为 LSF 系数,LSF 系数经过量化、反量化后还原成 LPC 系数,用于随后的信号重建函数 SKP_Silk_NSQ_wrapper_FLP 。
代码语言:javascript复制void SKP_Silk_find_pred_coefs_FLP(
SKP_Silk_encoder_state_FLP *psEnc, /* I/O Encoder state FLP */
SKP_Silk_encoder_control_FLP *psEncCtrl, /* I/O Encoder control FLP */
const SKP_float res_pitch[] /* I Residual */
)
SKP_Silk_NSQ_wrapper_FLP是编码模块前的重建分析函数。其思想是 Analysis by sythesis,即在这个函数里,会有一个模拟的解码器,使用上述线性预测参数、增益、量化残差等对语音信号进行重建,重建的信号会直接和当前编码信号进行比较,通过噪声整形、随机残差扰动等方法,使模拟解码器内的重建信号和输入信号的误差尽量小。这样就可以使得真正解码器的解码信号尽量逼近原始信号。
代码语言:javascript复制void SKP_Silk_NSQ_wrapper_FLP(
SKP_Silk_encoder_state_FLP *psEnc, /* I/O Encoder state FLP */
SKP_Silk_encoder_control_FLP *psEncCtrl, /* I/O Encoder control FLP */
const SKP_float x[], /* I Prefiltered input signal */
SKP_int8 q[], /* O Quantized pulse signal */
SKP_int8 *q_md[], /* O Quantized pulse signal */
const SKP_int useLBRR /* I LBRR flag */
)
该函数进行分析的模式有两种,SKP_Silk_NSQ 和 SKP_Silk_NSQ_del_dec。这两者最大的不同是,SKP_Silk_NSQ_del_dec 使用了Delay-Decision,其复杂度要高于 SKP_Silk_NSQ。但因为 Delay-Decision 的本质是把 Silk 中各个残差点的标量量化转化为了32个点的矢量量化,所以其效果比较好。因此Silk默认使用的是 SKP_Silk_NSQ_del_dec,本文只对该默认函数进行分析。
代码语言:javascript复制void SKP_Silk_NSQ_del_dec(
SKP_Silk_encoder_state *psEncC, /* I/O Encoder State */
SKP_Silk_encoder_control *psEncCtrlC, /* I Encoder Control */
SKP_Silk_nsq_state *NSQ, /* I/O NSQ state */
SKP_Silk_nsq_state NSQ_md[MAX_INTERLEAVE_NUM], /* I/O NSQ state */
const SKP_int16 x[], /* I Prefiltered input signal */
SKP_int8 q[], /* O Quantized pulse signal */
SKP_int8 *q_md[ MAX_INTERLEAVE_NUM ], /* O Quantized qulse signal */
SKP_int32 r[], /* O Output residual signal */
const SKP_int LSFInterpFactor_Q2, /* I LSF interpolation factor in Q2 */
const SKP_int16 PredCoef_Q12[ 2 * MAX_LPC_ORDER ], /* I Prediction coefs */
const SKP_int16 LTPCoef_Q14[ LTP_ORDER * NB_SUBFR ], /* I LT prediction coefs */
const SKP_int16 AR2_Q13[ NB_SUBFR * MAX_SHAPE_LPC_ORDER ], /* I Noise shaping filter */
const SKP_int HarmShapeGain_Q14[ NB_SUBFR ], /* I Smooth coefficients */
const SKP_int Tilt_Q14[ NB_SUBFR ], /* I Spectral tilt */
const SKP_int32 LF_shp_Q14[ NB_SUBFR ], /* I Short-term shaping coefficients */
const SKP_int32 Gains_Q16[ NB_SUBFR ], /* I Gain for each subframe */
const SKP_int32 MDGains_Q16[ NB_SUBFR ], /* I New gain, no use now */
const SKP_int32 DeltaGains_Q16, /* I Gain for odd subframe */
const SKP_int Lambda_Q10, /* I Quantization coefficient */
const SKP_int LTP_scale_Q14 /* I LTP state scaling */
)
在该函数内,核心操作有以下几步:
1)通过 Agora_Silk_DelDec_Rewhitening、Agora_Silk_DelDec_Rewhitening_Side 和 SKP_Silk_nsq_del_dec_scale_states等函数初始化合成码流和两个多描述码流的状态,准备在编码器内进行各条码流的模拟解码。
2)接下来在编码前的操作都是在 SKP_Silk_md_noise_shape_quantizer_del_dec中完成的,该函数完成了所有解码分析的操作,分析的第一步是在 SKP_Silk_md_noise_shape_quantizer_del_dec 中使用各个量化后的参数进行残差的求取。
3)求取残差后,区别于Silk会对单残差进行分析,SOLO 会使用特殊的增益DeltaGains_Q16 将残差按子帧划分为两条子帧能量互补的残差流,相邻子帧所进行增益分配的方式是相反的。
4)随后,SOLO 会在 Agora_Silk_RDCx1中计算两条流的两种不同量化方式的误差并将累积误差保存起来。随后,在 Agora_Silk_CenterRD中,SOLO 会计算两条残差两两组合起来的合成残差与实际残差的误差,并依据此误差和两条流各自的误差计算出一个加权误差,该加权误差的累积最终决定了使用哪两条残差码流作为编码的对象。计算加权误差所使用的权重 INTERNAL_JOINT_LAMBDA 可以进行调整,权重越靠向合成误差,那解码端无丢包下两条码流合成的音频的误差就越小;权重越靠向多描述码流的误差,解码端各条码流单独解码的信号误差就越小,但两条码流合成后的误差可能会较大。
最终,该函数会输出两条用于编码的残差信号和一个扰动的初始 seed,因为 seed会随着每个时域点的幅值信息进行变化,所以只需编码该帧的扰动初始 seed,解码器就可以推算出该帧所有时域点对应的扰动,用于后续编码。
SOLO 的低频部分编码沿用了 Silk 的编码方案(高频部分使用了独立的编码方案,具体实现可见于前一篇 SOLO 代码解读),所有需要编码的低频信息全部都在 SKP_Silk_encode_parameters中使用 range coding 进行编码。range coding 编码新增参数所需要的概率密度函数是根据大量中英文语料计算出来的,在一定程度上是编码效率较高的概率密度函数。
代码语言:javascript复制void SKP_Silk_encode_parameters(
SKP_Silk_encoder_state *psEncC, /* I/O Encoder state */
SKP_Silk_encoder_control *psEncCtrlC, /* I/O Encoder control */
SKP_Silk_range_coder_state *psRC, /* I/O Range encoder state */
SKP_intmd_type, /* I Use MDC or not */
const SKP_int8 *q /* I Quantization indices */
)
解码模块
在进行了高低频码流分离后,携带低频信息的码流被送到低频解码器,低频解码器可以看做是两个并行的 Silk 解码器加上前后处理模块。其中,前处理模块的主要功能是根据不同收包情况对码流进行分割。收包情况分为四种,分别为:
1. 只收到第一条描述码流;
2. 只收到第二条描述码流;
3. 两条码流都收到;
4. 该帧对应码流都没有收到。
SOLO 根据不同收包情况设置不同参数,传入 AgoraSateDecodeTwoDesps 进行解码。
代码语言:javascript复制SKP_int AgoraSateDecodeTwoDesps(
SKP_Silk_decoder_state *psDec, /* I/O Silk decoder state */
SKP_Silk_decoder_control*psDecCtrl,
SKP_int16 pOut[], /* O Output speech frame */
const SKP_int nBytes1, /* I Payload length */
const SKP_int nBytes2, /* I Payload length */
const SKP_uint8 pCode1[], /* I Pointer to payload */
const SKP_uint8 pCode2[], /* I Pointer to payload */
SKP_intdesp_type,
SKP_int decBytes[] /* O Used bytes */
)
在该函数里,通过 SKP_Silk_decode_parameters可以从码流中解码出增益、线性预测系数以及残差信号等重建音频所需要的信息。需要注意的是,在前两种收包情况下,解码出的残差并不是完整的残差,而是两段互补残差中的一段。但因为另一段互补码流没有按时到达解码器,所以解码器无法获得另一段互补残差。因此,解码出当前残差后,需要使用在编码器中计算并传输到解码器的特殊增益将该残差恢复为完整的残差信号;如果当前收包情况是第三种,那只需要将解码出的两条互补残差相加,即可得到完整的残差数据。得到残差信号后,再结合其他参数就可以进行语音信号的重建;如果当前收包情况是第四种,那么 SOLO 会去呼叫丢包补偿模块,使用上一帧的增益和线性预测系数,以及随机的残差信号进行补偿帧的生成。
最后,在经过一些和 Silk 相同的后处理后,解码器的流程就结束了。