在上一篇文章中,我们实现了多音轨同步音频播放器的核心架构。该系统的核心原理基于PCM音频的物理特性:音频时长与数据字节数存在严格的正比关系。以单通道音频为例,当采样率为10kHz、位深为16-bit时,单秒音频产生的数据量为:10000(采样点) × 1(通道) × 2(字节) = 20,000字节。这意味着特定字节数必然对应精确的播放时长。
基于此原理,我们的同步机制分为两个阶段:
- 将各音轨统一解码为标准PCM格式
- 严格按字节数对齐策略进行混音处理 每次从所有音轨提取等量PCM数据块进行混音后提交播放,通过字节级别的精确控制,确保所有音轨的播放进度实现亚毫秒级同步。
该方案必须满足三项基础条件:
- 所有音轨采样率一致
- 音频位深度相同
- 声道数量相等
对于参数不一致的混合音轨场景,我们设计了预处理解决方案:
• 声道转换模块:处理单声道/双声道的简单相互转换
• 采样率同步系统:整合libsamplerate专业库与自主研发的线性插值器
下文将深入解析这两种关键技术的实现方案。
应用下载: Demo apk下载地址 源码获取: Demo github地址
声道转换技术实现
单声道→立体声转换
实现原理:通过样本复制实现声道扩展,将单声道数据镜像到双声道。例如样本序列[1,2,3]转换为[1,1,2,2,3,3],新声道数据与原声道保持完全一致。
关键代码逻辑:
class MonoToStereoReSampler : ReSampler {
override fun reSampler(pcmData: ShortsInfo): ShortsInfo {
// 每个样本复制到左右声道(index/2实现双倍采样)
val shorts = ShortArray(pcmData.size * 2) { index ->
pcmData.shorts[pcmData.offset + index / 2]
}
return ShortsInfo(shorts, 0, shorts.size, pcmData.sampleTime, pcmData.flags)
}
}
优点
- 计算零损耗:时间复杂度O(n), 且仅通过内存复制实现声道扩展,无任何算术运算
- 无损还原:保持原始波形绝对一致性,避免信号畸变
缺点
- 伪立体声问题:左右声道数据完全镜像,缺乏真实立体声场空间感
立体声→单声道转换
实现原理:采用加权混合与动态防溢出机制:
- 双声道样本相加(L+R)
- 通过衰减因子控制幅值
- 动态调整衰减因子防止削波
技术实现要点:
- 削峰保护:当混合值超过Short范围时,计算瞬时衰减因子
- 衰减恢复:通过STEP_SIZE控制衰减因子逐步回归1.0
- 溢出处理:强制钳制到Short类型最大值
class StereoToMonoReSampler : ReSampler {
private var attenuationFactor = 1f // 动态衰减系数
override fun reSampler(pcmData: ShortsInfo): ShortsInfo {
val size = pcmData.size / 2
val shorts = ShortArray(size) { index ->
// 声道混合计算
var mixValue = pcmData.shorts[index
2 + pcmData.offset] + * pcmData.shorts[index*
2 + 1 + pcmData.offset]
// 动态幅值控制
mixValue = (mixValue * attenuationFactor).toInt()
when {
mixValue > SHORT_MAX -> { // 正向溢出处理
attenuationFactor = SHORT_MAX_FLOAT / mixValue
mixValue = SHORT_MAX
}
mixValue < SHORT_MIN -> { // 负向溢出处理
attenuationFactor = SHORT_MIN_FLOAT / mixValue
mixValue = SHORT_MIN
}
}
// 衰减系数恢复(STEP_SIZE控制恢复速度)
if (attenuationFactor < 1) {
attenuationFactor += (1 - attenuationFactor) / STEP_SIZE
}
mixValue.toShort()
}
return ShortsInfo(shorts, 0, shorts.size, pcmData.sampleTime, pcmData.flags)
}
companion object {
const val SHORT_MAX = 32767
const val SHORT_MAX_FLOAT = 32767f
const val SHORT_MIN = -32768
const val SHORT_MIN_FLOAT = -32768f
const val STEP_SIZE = 32
}
}
优点
- 信息保全:通过L+R混合保留双声道能量特征
- 动态保护机制:实时检测样本值域,自动触发衰减因子计算
- 平滑恢复策略:保证衰减因子以一定步长渐进回归,避免幅值突变
缺点
- 相位抵消风险:当L/R声道存在180°相位差时,混合导致信号部分抵消
- 计算复杂度:每个样本需执行4次条件判断+2次浮点运算,增加约15%CPU负载
- 瞬态失真:衰减因子调整期间可能出现一定时长的幅值波动(与STEP_SIZE参数强相关)
采样率转换技术实现
kotlin实现线性插值
技术原理
采样率转换的核心目标是在时间轴上重新分配音频信号的采样点。当目标采样率与原始采样率不同时,需要通过插值算法估算新采样点的数值。线性插值采用两点连线估算的方式,在任意两个相邻的原始采样点之间建立一条直线,通过这条直线上的点生成新的采样值。
技术实现要点:
- 比率计算:确定新旧采样率比例
ratio = newRate / oldRate - 索引定位:新采样点对应原始信号的虚拟位置
virtualPos = i / ratio - 邻点选取:取相邻两个原始采样点
x[k]和x[k+1] - 权重混合:根据小数部分
alpha = virtualPos - k计算加权和
class LinearReSampler(
private val oldRate: Int,
private val newRate: Int,
private val channels: AudioTranscoder.Channels,
) : ReSampler {
override fun reSampler(pcmData: ShortsInfo): ShortsInfo {
val ratio = newRate.toFloat() / oldRate.toFloat()
val oldSize = pcmData.size / channels.value
val newSize = (oldSize * ratio).toInt()
val newShorts = ShortArray(newSize * channels.value)
// 多声道独立处理
for (channel in 0 until channels.value) {
for (i in 0 until newSize) {
// 虚拟位置计算
val virtualPos = i / ratio
val originalIndex = virtualPos.toInt()
val nextIndex = min(originalIndex + 1, oldSize - 1)
// 邻点采样
val originalSample = pcmData.shorts[originalIndex * channels.value + channel]
val nextSample = pcmData.shorts[nextIndex * channels.value + channel]
// 线性插值计算
val alpha = virtualPos - originalIndex
newShorts[i * channels.value + channel] =
((1 - alpha) * originalSample + alpha * nextSample).toInt().toShort()
}
}
return ShortsInfo(newShorts, 0, newShorts.size, pcmData.sampleTime, pcmData.flags)
}
}
优点
- 计算效率高 : 单次插值仅需2次乘法和1次加法 , 时间复杂度为O(N*C),N为采样点数,C为声道数
缺点
- 高频信号失真
JNI 调用libsamplerate
技术原理
libsamplerate(Secret Rabbit Code)是业界公认的高质量采样率转换库,其核心算法基于sinc函数构建的数字滤波器。相比简单的线性插值,libsamplerate通过以下技术实现更精准的信号重建,提供多种插值算法:
- SRC_SINC_BEST_QUALITY :最高质量8阶sinc滤波
- SRC_LINEAR :线性插值(与自主实现方案等效)
- SRC_SINC_FASTEST :中等质量快速算法
核心实现步骤:
-
初始化重采样器
- 创建SRC_STATE对象存储重采样上下文
- 设置采样率转换比
extern "C"
JNIEXPORT jlong JNICALL Java_com_example_JniReSampler_initReSampler(
JNIEnv* env, jobject, jint channels) {
SRC_STATE* srcState = src_new(SRC_SINC_BEST_QUALITY, channels, &error);
src_set_ratio(srcState, newRate / oldRate);
return reinterpret_cast<jlong>(srcState);
}
-
重采样
- 将java层传入的short数组 进行归一化 转换成 float数组
- 调用
src_process进行重采样 - 将重采样结果反归一化成java的short数组
- 释放资源
extern "C"
JNIEXPORT jshortArray JNICALL
Java_com_example_syncplayer_audio_resample_JniReSampler_resampleFromJNI(
JNIEnv *env,
jobject,
jshortArray shorts,
jint length,
jlong re_sampler_pointer) {
jshort *shortArrayElements = env->GetShortArrayElements(shorts, nullptr);
// 将 jshortArray shorts 进行归一化
float* input = ....
SRC_DATA srcData;
int channels = srcState->channels;
// 设置采样前的原数据
srcData.data_in = input;
// 设置有多少个采样点
srcData.input_frames = length / channels;
// 采样比率
srcData.src_ratio = srcState->last_ratio;
long outLength = static_cast<long>(length * srcData.src_ratio) + 1;
// 设置重采样后的采样点数量
srcData.output_frames = outLength / channels;
auto *output = (float *) malloc(sizeof(float) * outLength);
// 设置输出地址
srcData.data_out = output;
// 进行重采样
int error = src_process(srcState, &srcData);
jshortArray result = ... 将out进行反归一化
// 释放资源
free(output);
return result;
}
- 释放资源
extern "C"
JNIEXPORT void JNICALL
Java_com_example_syncplayer_audio_resample_JniReSampler_releaseReSampler(JNIEnv *env, jobject thiz,
jlong re_sampler_pointer) {
src_delete((SRC_STATE *) re_sampler_pointer);
}
优点
-
转换效果更好
缺点
- 算法更复杂,性能开销大,耗时更多
- 会导入杂音,原因未知