Skip to content

音频淡入淡出及音量调节算法

音频淡入淡出概述

淡入淡出(fade in & fade out)可以实现音频音量在开始播放时渐强,以及停止播放时渐弱,防止声音切换带来的音量突变。实现淡入淡出重点要解决的问题,在于如何调节 PCM 数据的音量大小。一开始我天真的认为,只要把每一个采样值加上或减去某个数就可以了,但事情可没这么简单,加上一个数虽然能让采样值增大,但却会造成声音波形失真,并不可行。

淡入淡出本质上是对音频音量作线性变换,可以通过对每个采样值乘上一个变换系数实现,并且这个系数应该是个随时间变化量,如最简单的线性函数 y = kx。但在实际应用中,处理范围内 y 需在 0~1 之间变化,对应音量从零到原始值,自变量 x 可看作某个音频位置(时间、采样序号等),假设需要在 x = 500 位置处音量渐变到最大,则算出 k = 1/500。

img

算法原理(以淡入为例)

对给定的 t1-t2 时间区间的单声道音频采样进行淡入操作(t1一般取0),先把时间统一换算为采样数,作为基本单位,然后计算已处理的采样数占待处理采样总数比例,得到出下一采样对应的变换系数,再与采样相乘,得到输出采样值。

举个栗子,要对 100 个采样从头开始、依次进行淡入处理,在第 0、50、70 个采样处的计算方式如下:

factor = 0 / 100;
out_sample[0] = out_sample[0] * factor;

factor = 50 / 100;
out_sample[50] = out_sample[50] * factor;

factor = 70 / 100;
out_sample[70] = out_sample[70] * factor;

值得注意的是,变换系数为 0~1 的小数,在实际 C 编程中,为了避免浮点运算,通常把上面两步计算通过一条表达式完成,先计算分子乘法再算分母除法:

out_sample[0]  = (out_sample[0] * 0) / 100;
out_sample[50] = (out_sample[50] * 50) / 100;
out_sample[70] = (out_sample[70] * 70) / 100;

综上,对算法步骤进行整理,得到以下计算流程: 1. 通过采样率和声道数计算开始时刻、结束时刻、总处理时长对应的采样数 start_sample_pos、stop_sample_pos、fade_samples_count=stop_sample_pos-start_sample_pos 2. 计算已处理采样数:done_sample_count = cur_sample_pos - start_sample_pos 3. 计算表达式分子乘法,得到中间变量:tmp = out_sample * done_sample_count 4. 计算表达式分母除法,得到输出采样值:out_sample = tmp / fade_samples_count 5. 更新当前采样位置:cur_sample_pos++

性能优化

对于分母除法, fade_samples_count 只需计算一次,后面不变,而分子需要每次动态计算乘法,比较悲催的是即使就这点计算量,也并不是所有硬件都能 hold 得住,在 cpu 资源紧张的硬件上运行乘除法,会消耗一定时间,甚至造成部分音频卡顿现象。

13758: 9802 ldr r0, [sp, #8] 1375a: 9903 ldr r1, [sp, #12] 1375c: 2300 movs r3, #0 1375e: 002a movs r2, r5 13760: f086 fe04 bl 9a36c <____aeabi_ldivmod_from_thumb> 13764: 2101 movs r1, #1 13766: 8030 strh r0, [r6, #0] 13768: 2018 movs r0, #24

从反汇编代码看出,我调试所用硬件架构没有除法指令,只能通过调用<____aeabi_ldivmod_from_thumb>实现计算除法。导致 cpu 占用过高,没办法所以要对算法进一步优化,主要是运算符的精简。

优化方案一 —— 移位替代分母除法

保持算法思路不变,将上述步骤 4 「计算表达式分母除法部分」中除法采用右移替换,但由于移位只适用乘除 2 的整数次幂,需要先把分母 fade_samples_count 向下取最接近的 2 次幂整数代替,如 96000 用 65536 替代,再进行右移操作:

tmp / 96000 => tmp / 65536 => tmp >> 16 相应的代码实现可以一步到位:计算除数对应二进制位数,再减一得到所需右移位数(96000 有 17 位二进制数,右移 16 位),代码实现:

int64_t tmp;
int bitcount = 0;

tmp = fade_samples_count;
while (tmp > 0)
{
    tmp = tmp / 2;
    bitcount++;
}

tmp = out_sample[i] * (int64_t)(f_info->cur_sample_pos-f_info->start_sample_pos);
out_sample[i] = tmp >> (bitcount-1); 
这种替换能直接避免使用除法,实测性能提升 10 倍,但缺陷也很明显,那就是原操作数会损失不定量的数值(取决于与最近的 2 次幂有多靠近),从而造成计算结果误差。上面例子中 96000 直接少了 3w 多,具体效果表现为,淡入 3s 的音频实际只处理了 1s 多(可以通过其他手段补偿但还没尝试)。

因此本方案不适用于精确时间淡入淡出,只能算个大概,误差大小全看人品,但其实用于音乐播放影响不大。

优化方案二 —— 移位代替线性变化(用于提示音播放消 pop 声)

区分针对「听觉效果」与「硬件特性」的淡入淡出处理,可实现两套算法并根据具体情况调用 —— 播放音乐与播放提示音。短促的提示音甚至没必要通过动态计算线性变化系数处理,简陋的移位操作也许就能够起作用(效果待验证)。

前面所说的淡出淡入淡出效果,可适用于所有音频,能防止声音突然出现产生的突兀感,例如播放音乐时,人耳能明显感受到音量缓缓变化,暂且称为面向「听觉效果」的处理。而下面提到的第二种方案,适合对短促的提示音作淡入,可避免由于功放输入能量突变造成的 pop 音,处理目的更多的是面向「硬件特性」的优化,是另一种算法思路。

这实际上是对线性变换的一种简化,不再通过除法就计算变换系数,而是直接把采样值移位,达到近似的效果(或者看做固定几个变换系数的线性变换)。这时编程关注的重点在于移位操作本身 —— 从哪移到哪?在什么时机移?移多少位?

理论分析

考虑 16 位长度的采样值,在淡入处理时从最小音量到最大音量的过程中,每次移一位,需要经过最多 16 次左移操作,即移位总次数等于采样位宽,因此整个音频音量呈现出 16 级阶梯状变化,且每一级内采样点的音量是前一级的 2 倍,相比线性变换方式,音量增加存在锯齿状。

img

要保证 16 次移位后音量刚好达到最大,就要先计算每隔多久移位一次,可以通过总采样数 fade_samples_count 除以 16 得到(每一级内的采样数),每当达到一个移位间隔,执行一次移位。例如需要处理 1600 个采样,就是每 100 个采样移一位。

利用整除可以判断当前采样处于第几级,并通过右移递减的方式模拟左移递增,达到数据「一位一位冒出来」的效果,示意代码:

level = fade_samples_count / 16;

while (cur_sample_pos < stop_sample_pos)
{
    tmp = cur_sample_pos / level;
    out_sample[i] = out_sample[i] >> (16 - tmp);
}

同样,循环体中的除法必须干掉,同样使用移位代替整除,但你懂得,还是先要把除数近似到 2 次幂(计算二进制位数一步到位),并且由于截断误差原因,一部分采样数被舍弃掉,算出来的最大 level 或许达不到 16 级,音量级数变化范围改为 0 ~ bitcount,改写代码:

level = fade_samples_count / 16;
tmp = level;
while (tmp > 0)
{
    tmp = tmp / 2;
    bitcount++;
}

while (cur_sample_pos < stop_sample_pos)
{
    tmp = cur_sample_pos >> bitcount;
    out_sample[i] = out_sample[i] >> (bitcount - tmp);
}
随着移位二进制权值的递增,音量变化会越来越大,运行效果听起来便是:前面一大段时间音量增大幅度都很小,最后一小段音量急剧上升,听音乐还是有点突兀,但用于短促提示音可消除 pop 声并快速进入音频内容,当然了,将第一种算法的淡入时间改短也能达到相同效果。

PCM音频数据处理---音量增大或减小

原理分析

  1. 硬件音量控制:语音芯片的音量控制一般分为8级音量控制和16级音量控制。但是语音芯片的硬件音量控制是怎么控制两种音频输出的音量的呢?一般是采用调整电流的方式来控制音量的输出。控制PWM电流就可以控制输出到喇叭上的电流强度,从而控制喇叭振幅的大小,从而控制我们人感知的音量大小。DAC音频输出方式,同样也是控制电流形式,因为语音芯片大多数一般都是电流型 DAC 只要控制 DAC 的电流就可以控制外部三极管的基极电流,从而控制喇叭上的电流强度达到音量调节的目的。

  2. 软件音量控制:由于软件调节音量不能直接控制PWM和 DAC上的电流,所以软件音量控制一般是直接调整输送到音频合成器的数值,达到音量控制的目的。所以只要通过一定的数学运算,就可以对输送到音频合成器的数值进行调制。理论上软件音量控制可以任意级数。但是由于受到CPU运算能力的影响和实际应用的需求一般也是做16级音量控制。如果运算能力有限也可以做⒉级或者4级音量控制。

代码实现

通过编程实现调整PCM的音量,具体做法是乘上一个固定的数,但是要考虑数据的溢出问题。下面是一些数据进行参考。 经过测试: 1. 如果是通过pwm输出驱动喇叭,可能该方法不适用,因为你减小的是占空比,不是波形的幅值。所以应该只适用于DAC方式输出。 2. PWM方式可以在输出部分串一个可调电阻当做音量旋钮,用来改变pwm的幅值而不是占空比。我觉得可行,还没进行测试。

int frame_size_get(int inSampleRate, int ChannleNumber)
{
    int size= -1;
    switch(inSampleRate)
    {
    case 8000:
        {
            size= 320;
        }
        break;
    case 16000:
        {
            size= 640;
        }
        break;
    case 24000:
        {
            size= 960;
        }
        break;
    case 32000:
        {
            size= 1280;
        }
        break;
    case 48000:
        {
            size= 1920;
        }
        break;
    case 44100:
        {
            size= 441*4;//为了保证8K输出有320,441->80,*4->320
        }
        break;
    case 22050:
        {
            size= 441*2;
        }
        break;
    case 11025:
        {
            size= 441;
        }
        break;
    default:
        break;
    }
    return ChannleNumber*size;
}


void RaiseVolume(char* buf, UINT32 size,UINT32 uRepeat,double vol)
{
 if (!size )
 {
  return;
 }
 for (int i = 0; i < size;)
 {
  signed long minData = -0x8000; //如果是8bit编码这里变成-0x80
  signed long maxData = 0x7FFF;//如果是8bit编码这里变成0xFF

  signed short wData = buf[i+1];
  wData = MAKEWORD(buf[i],buf[i+1]);
  signed long dwData = wData;

  for (int j = 0; j < uRepeat; j++)
  {
   dwData = dwData * vol;//1.25;
   if (dwData < -0x8000)
   {
    dwData = -0x8000;
   }
   else if (dwData > 0x7FFF)
   {
    dwData = 0x7FFF;
   }
  }
  wData = LOWORD(dwData);
  buf[i] = LOBYTE(wData);
  buf[i+1] = HIBYTE(wData);
  i += 2;
 }
}


//vol取0—10即可,为0时为静音,小于1声音减小,大于1声音增大,测试取大于10的数字效果不明显
int pcm_volume_control(char* foldname, char* fnewname, double vol)
{
    HXD_WAVFLIEHEAD head;
    int frame_size= 0;
    char* frame_buffer;
    FILE* fp_old= fopen(foldname,"rb+");
    FILE* fp_new= fopen(fnewname,"wb+");
    if((NULL== fp_old) || (NULL== fp_new))
    {
        return -1;
    }
    fread(&head,1,sizeof(head),fp_old);
    fwrite(&head,1,sizeof(head),fp_new);
    frame_size= frame_size_get( head.nSampleRate,head.nChannleNumber);
    frame_buffer= (char*)malloc(frame_size);

    while(frame_size== fread(frame_buffer,1,frame_size,fp_old))
    {
  RaiseVolume(frame_buffer,frame_size,1,vol);
        fwrite(frame_buffer,1,frame_size,fp_new);
    }
    fclose(fp_old);
    fclose(fp_new);
    free(frame_buffer); 
    return 0;
}


void CAudioControlDlg::OnButtonAdd()
{
 // TODO: Add your control notification handler code here
 pcm_volume_control("old.wav","new.wav",5); 

}
//16bits长度音量数据处理
// 音量控制
// output: para1 输出数据
// input : para2 输入数据
//         para3 输入长度
//         para4 音量控制参数,有效控制范围[0,100]
int volume_control(short* out_buf,short* in_buf,int in_len, float in_vol)
{
    int i,tmp;

    // in_vol[0,100]
    float vol = in_vol - 98;    

    if(-98 < vol  &&  vol <0 )
    {
        vol = 1/(vol*(-1));
    }
    else if(0 <= vol && vol <= 1)
    {
        vol = 1;
    }
    /*else if(1 < vol && vol <= 2)
    {
        vol = vol;
    }*/
    else if(vol <= -98)
    {
        vol = 0;
    }
    else if(2 <= vol)
    {
        vol = 2;
    }   

    for(i=0; i<in_len/2; i++)
    {
        tmp = in_buf[i]*vol;
        if(tmp > 32767)
        {
            tmp = 32767;
        }
        else if( tmp < -32768)
        {
            tmp = -32768;
        }
        out_buf[i] = tmp;
    }

    return 0;
}

// 16bit_to_8bit(注意输出数据是有符号的还是无符号的)
// output:   para 1: 8bit的数据
// input:    para 2: 16bit数据       
//           para 3: 要转换数据的次数,为原数据长度的一半,因为一次转换2个字节
int mono_16bit_to_8bit(unsigned char* lp8bits, short* lp16bits, int len)
{
    int i=0;
    for(i=0; i<len; i++) {
        *lp8bits++ = ((*lp16bits++) >> 8) + 128;
    }   

    return i>>1;
}

// 8bit_to_16bit(注意:输出数据是有符号的还是无符号的)
// output:   para 1: 16bit的数据
// input:    para 2: 8bit数据       
//           para 3: 要转换数据的次数,为原数据长度,

//注:因为一次转换1个字节变两个字节,所以转换后数据的总长度为8位数据的两倍
int mono_8bit_to_16bit(short* lp16bits, unsigned char* lp8bits, int len)
{
    int i=0;
    for(i=0; i<len; i++) {
        *lp16bits++ = ((*lp8bits++) -128) << 8;
    }   

    return i<<1;
}