音视频大合集,先从零开始万事开头难

·  阅读 2587
音视频大合集,先从零开始万事开头难

前言

周六按时发了工资。好好休息了两天,出去消费了一波美滋滋🤣🤣🤣

顺带有粉丝找我问有没有openGL ES比较深入的学习内容,和Recyclerview的.抽空弄一波。

前几天零零散散的更新了一些音视频的片段,今天就分为初中级三个方面来全面的分析下,新更新的内容后续在说。

新增:Flutter番外篇:Flutter面试-项目实战-电子书;openGL ES深入版+Recyclerview

关注公众号:Android苦做舟
解锁 《Android十一大板块文档》
音视频大合集,从初中高到面试应有尽有;让学习更贴近未来实战。已形成PDF版

十一个模块内容如下

1.2022最新Android11位大厂面试专题,128道附答案
2.音视频大合集,从初中高到面试应有尽有
3.Android车载应用大合集,从零开始一起学
4.性能优化大合集,告别优化烦恼
5.Framework大合集,从里到外分析的明明白白
6.Flutter大合集,进阶Flutter高级工程师
7.compose大合集,拥抱新技术
8.Jetpack大合集,全家桶一次吃个够
9.架构大合集,轻松应对工作需求
10.Android基础篇大合集,根基稳固高楼平地起
11.Flutter番外篇:Flutter面试+项目实战+电子书

整理不易,关注一下吧。开始进入正题,ღ( ´・ᴗ・` ) 🤔

一丶通过三种方式绘制图片

在 Android 音视频开发学习思路里面,我们写到了,想要逐步入门音视频开发,就需要一步步的去学习整理,并积累。本文是音视频开发积累的第一篇。 对应的要学习的内容是:在 Android 平台绘制一张图片,使用至少 3 种不同的 API,ImageView,SurfaceView,自定义 View。

ImageView 绘制图片

这个想必做过Android开发的都知道如何去绘制了。很简单:

Bitmap bitmap = BitmapFactory.decodeFile(Environment.getExternalStorageDirectory().getPath() + File.separator + "11.jpg");
imageView.setImageBitmap(bitmap);
复制代码

很轻松,在界面上看到了我们绘制的图片。

SurfaceView 绘制图片

这个比 ImageView 绘制图片稍微复杂一点点:

SurfaceView surfaceView = (SurfaceView) findViewById(R.id.surface);
surfaceView.getHolder().addCallback(new SurfaceHolder.Callback() {
    @Override
    public void surfaceCreated(SurfaceHolder surfaceHolder) {

        if (surfaceHolder == null) {
            return;
        }

        Paint paint = new Paint();
        paint.setAntiAlias(true);
        paint.setStyle(Paint.Style.STROKE);

        Bitmap bitmap = BitmapFactory.decodeFile(Environment.getExternalStorageDirectory().getPath() + File.separator + "11.jpg");  // 获取bitmap
        Canvas canvas = surfaceHolder.lockCanvas();  // 先锁定当前surfaceView的画布
        canvas.drawBitmap(bitmap, 0, 0, paint); //执行绘制操作
        surfaceHolder.unlockCanvasAndPost(canvas); // 解除锁定并显示在界面上
    }

    @Override
    public void surfaceChanged(SurfaceHolder surfaceHolder, int i, int i1, int i2) {

    }

    @Override
    public void surfaceDestroyed(SurfaceHolder surfaceHolder) {

    }
});
复制代码

自定义 View 绘制图片

这个有绘制自定义View经验的可以很轻松的完成,本人也简单整理过 Android 自定义 View 绘制这一块的知识:

public class CustomView extends View {

    Paint paint = new Paint();
    Bitmap bitmap;

    public CustomView(Context context) {
        super(context);
        paint.setAntiAlias(true);
        paint.setStyle(Paint.Style.STROKE);
        bitmap = BitmapFactory.decodeFile(Environment.getExternalStorageDirectory().getPath() + File.separator + "11.jpg");  // 获取bitmap
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        // 不建议在onDraw做任何分配内存的操作
        if (bitmap != null) {
            canvas.drawBitmap(bitmap, 0, 0, paint);
        }
    }
}
 
复制代码

注:别忘记了权限,否则是不会展示成功的。

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
复制代码

这三种方式都成功了展示出来了,我们可以继续学习并整理后面的知识了

二丶使用 AudioRecord 采集音频PCM并保存到文件

AudioRecord API详解

AudioRecord是Android系统提供的用于实现录音的功能类。

要想了解这个类的具体的说明和用法,我们可以去看一下官方的文档:

AndioRecord类的主要功能是让各种JAVA应用能够管理音频资源,以便它们通过此类能够录制声音相关的硬件所收集的声音。

此功能的实现就是通过”pulling”(读取)AudioRecord对象的声音数据来完成的。在录音过程中,应用所需要做的就是通过后面三个类方法中的一个去及时地获取AudioRecord对象的录音数据. AudioRecord类提供的三个获取声音数据的方法分别是read(byte[], int, int), read(short[], int, int), read(ByteBuffer, int). 无论选择使用那一个方法都必须事先设定方便用户的声音数据的存储格式。 

开始录音的时候,AudioRecord需要初始化一个相关联的声音buffer, 这个buffer主要是用来保存新的声音数据。这个buffer的大小,我们可以在对象构造期间去指定。它表明一个AudioRecord对象还没有被读取(同步)声音数据前能录多长的音(即一次可以录制的声音容量)。声音数据从音频硬件中被读出,数据大小不超过整个录音数据的大小(可以分多次读出),即每次读取初始化buffer容量的数据。

实现Android录音的流程为

  • 构造一个AudioRecord对象,其中需要的最小录音缓存buffer大小可以通过getMinBufferSize方法得到。如果buffer容量过小,将导致对象构造的失败。
  • 初始化一个buffer,该buffer大于等于AudioRecord对象用于写声音数据的buffer大小。
  • 开始录音
  • 创建一个数据流,一边从AudioRecord中读取声音数据到初始化的buffer,一边将buffer中数据导入数据流。
  • 关闭数据流
  • 停止录音

使用 AudioRecord 实现录音,并生成wav

创建一个AudioRecord对象

首先要声明一些全局的变量参数:

private AudioRecord audioRecord = null;  // 声明 AudioRecord 对象
private int recordBufSize = 0; // 声明recoordBufffer的大小字段
复制代码

获取buffer的大小并创建AudioRecord:

public void createAudioRecord() {
  recordBufSize = AudioRecord.getMinBufferSize(frequency, channelConfiguration, EncodingBitRate);  //audioRecord能接受的最小的buffer大小
   audioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, frequency, channelConfiguration, EncodingBitRate, recordBufSize);
}
复制代码

初始化一个buffer

byte data[] = new byte[recordBufSize]; 
复制代码

开始录音

audioRecord.startRecording();
isRecording = true;
复制代码

创建一个数据流,一边从AudioRecord中读取声音数据到初始化的buffer,一边将buffer中数据导入数据流。

FileOutputStream os = null;

try {
    os = new FileOutputStream(filename);
} catch (FileNotFoundException e) {
    e.printStackTrace();
}
if (null != os) {
    while (isRecording) {
        read = audioRecord.read(data, 0, recordBufSize);
      // 如果读取音频数据没有出现错误,就将数据写入到文件
        if (AudioRecord.ERROR_INVALID_OPERATION != read) {
            try {
                os.write(data);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    try {
        os.close();
    } catch (IOException e) {
        e.printStackTrace();
    }
} 
复制代码

关闭数据流

修改标志位:isRecording 为false,上面的while循环就自动停止了,数据流也就停止流动了,Stream也就被关闭了。

isRecording = false;
复制代码

停止录音

停止录音之后,注意要释放资源。

if (null != audioRecord) {
  audioRecord.stop();
   audioRecord.release();
  audioRecord = null;
   recordingThread = null;
}
复制代码

注:权限需求:WRITE_EXTERNAL_STORAGE、RECORD_AUDIO

到现在基本的录音的流程就介绍完了。但是这时候,有人就提出问题来了:

1)、我按照流程,把音频数据都输出到文件里面了,停止录音后,打开此文件,发现不能播放,到底是为什么呢?

答:按照流程走完了,数据是进去了,但是现在的文件里面的内容仅仅是最原始的音频数据,术语称为raw(中文解释是“原材料”或“未经处理的东西”),这时候,你让播放器去打开,它既不知道保存的格式是什么,又不知道如何进行解码操作。当然播放不了。

2)、那如何才能在播放器中播放我录制的内容呢?

答: 在文件的数据开头加入WAVE HEAD数据即可,也就是文件头。只有加上文件头部的数据,播放器才能正确的知道里面的内容到底是什么,进而能够正常的解析并播放里面的内容。具体的头文件的描述,在Play a WAV file on an AudioTrack里面可以进行了解。

添加WAVE文件头的代码如下:

public class PcmToWavUtil {

    /**
     * 缓存的音频大小
     */
    private int mBufferSize;
    /**
     * 采样率
     */
    private int mSampleRate;
    /**
     * 声道数
     */
    private int mChannel;


    /**
     * @param sampleRate sample rate、采样率
     * @param channel channel、声道
     * @param encoding Audio data format、音频格式
     */
    PcmToWavUtil(int sampleRate, int channel, int encoding) {
        this.mSampleRate = sampleRate;
        this.mChannel = channel;
        this.mBufferSize = AudioRecord.getMinBufferSize(mSampleRate, mChannel, encoding);
    }


    /**
     * pcm文件转wav文件
     *
     * @param inFilename 源文件路径
     * @param outFilename 目标文件路径
     */
    public void pcmToWav(String inFilename, String outFilename) {
        FileInputStream in;
        FileOutputStream out;
        long totalAudioLen;
        long totalDataLen;
        long longSampleRate = mSampleRate;
        int channels = mChannel == AudioFormat.CHANNEL_IN_MONO ? 1 : 2;
        long byteRate = 16 * mSampleRate * channels / 8;
        byte[] data = new byte[mBufferSize];
        try {
            in = new FileInputStream(inFilename);
            out = new FileOutputStream(outFilename);
            totalAudioLen = in.getChannel().size();
            totalDataLen = totalAudioLen + 36;

            writeWaveFileHeader(out, totalAudioLen, totalDataLen,
                longSampleRate, channels, byteRate);
            while (in.read(data) != -1) {
                out.write(data);
            }
            in.close();
            out.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }


    /**
     * 加入wav文件头
     */
    private void writeWaveFileHeader(FileOutputStream out, long totalAudioLen,
                                     long totalDataLen, long longSampleRate, int channels, long byteRate)
        throws IOException {
        byte[] header = new byte[44];
        // RIFF/WAVE header
        header[0] = 'R';
        header[1] = 'I';
        header[2] = 'F';
        header[3] = 'F';
        header[4] = (byte) (totalDataLen & 0xff);
        header[5] = (byte) ((totalDataLen >> 8) & 0xff);
        header[6] = (byte) ((totalDataLen >> 16) & 0xff);
        header[7] = (byte) ((totalDataLen >> 24) & 0xff);
        //WAVE
        header[8] = 'W';
        header[9] = 'A';
        header[10] = 'V';
        header[11] = 'E';
        // 'fmt ' chunk
        header[12] = 'f';
        header[13] = 'm';
        header[14] = 't';
        header[15] = ' ';
        // 4 bytes: size of 'fmt ' chunk
        header[16] = 16;
        header[17] = 0;
        header[18] = 0;
        header[19] = 0;
        // format = 1
        header[20] = 1;
        header[21] = 0;
        header[22] = (byte) channels;
        header[23] = 0;
        header[24] = (byte) (longSampleRate & 0xff);
        header[25] = (byte) ((longSampleRate >> 8) & 0xff);
        header[26] = (byte) ((longSampleRate >> 16) & 0xff);
        header[27] = (byte) ((longSampleRate >> 24) & 0xff);
        header[28] = (byte) (byteRate & 0xff);
        header[29] = (byte) ((byteRate >> 8) & 0xff);
        header[30] = (byte) ((byteRate >> 16) & 0xff);
        header[31] = (byte) ((byteRate >> 24) & 0xff);
        // block align
        header[32] = (byte) (2 * 16 / 8);
        header[33] = 0;
        // bits per sample
        header[34] = 16;
        header[35] = 0;
        //data
        header[36] = 'd';
        header[37] = 'a';
        header[38] = 't';
        header[39] = 'a';
        header[40] = (byte) (totalAudioLen & 0xff);
        header[41] = (byte) ((totalAudioLen >> 8) & 0xff);
        header[42] = (byte) ((totalAudioLen >> 16) & 0xff);
        header[43] = (byte) ((totalAudioLen >> 24) & 0xff);
        out.write(header, 0, 44);
    }
}
复制代码

附言

Android SDK 提供了两套音频采集的API,分别是:MediaRecorder 和 AudioRecord,前者是一个更加上层一点的API,它可以直接把手机麦克风录入的音频数据进行编码压缩(如AMR、MP3等)并存成文件,而后者则更接近底层,能够更加自由灵活地控制,可以得到原始的一帧帧PCM音频数据。如果想简单地做一个录音机,录制成音频文件,则推荐使用 MediaRecorder,而如果需要对音频做进一步的算法处理、或者采用第三方的编码库进行压缩、以及网络传输等应用,则建议使用 AudioRecord,其实 MediaRecorder 底层也是调用了 AudioRecord 与 Android Framework 层的 AudioFlinger 进行交互的。直播中实时采集音频自然是要用AudioRecord了。

三丶使用 AudioTrack 播放PCM音频

AudioTrack 基本使用

AudioTrack 类可以完成Android平台上音频数据的输出任务。AudioTrack有两种数据加载模式(MODE_STREAM和MODE_STATIC),对应的是数据加载模式和音频流类型, 对应着两种完全不同的使用场景。

  • MODE_STREAM:在这种模式下,通过write一次次把音频数据写到AudioTrack中。这和平时通过write系统调用往文件中写数据类似,但这种工作方式每次都需要把数据从用户提供的Buffer中拷贝到AudioTrack内部的Buffer中,这在一定程度上会使引入延时。为解决这一问题,AudioTrack就引入了第二种模式。
  • MODE_STATIC:这种模式下,在play之前只需要把所有数据通过一次write调用传递到AudioTrack中的内部缓冲区,后续就不必再传递数据了。这种模式适用于像铃声这种内存占用量较小,延时要求较高的文件。但它也有一个缺点,就是一次write的数据不能太多,否则系统无法分配足够的内存来存储全部数据。

MODE_STATIC模式

MODE_STATIC模式输出音频的方式如下(注意:如果采用STATIC模式,须先调用write写数据,然后再调用play。):

public class AudioTrackPlayerDemoActivity extends Activity implements
        OnClickListener {

    private static final String TAG = "AudioTrackPlayerDemoActivity";
    private Button button;
    private byte[] audioData;
    private AudioTrack audioTrack;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        super.setContentView(R.layout.main);
        this.button = (Button) super.findViewById(R.id.play);
        this.button.setOnClickListener(this);
        this.button.setEnabled(false);
        new AsyncTask<Void, Void, Void>() {
            @Override
            protected Void doInBackground(Void... params) {
                try {
                    InputStream in = getResources().openRawResource(R.raw.ding);
                    try {
                        ByteArrayOutputStream out = new ByteArrayOutputStream(
                                264848);
                        for (int b; (b = in.read()) != -1;) {
                            out.write(b);
                        }
                        Log.d(TAG, "Got the data");
                        audioData = out.toByteArray();
                    } finally {
                        in.close();
                    }
                } catch (IOException e) {
                    Log.wtf(TAG, "Failed to read", e);
                }
                return null;
            }

            @Override
            protected void onPostExecute(Void v) {
                Log.d(TAG, "Creating track...");
                button.setEnabled(true);
                Log.d(TAG, "Enabled button");
            }
        }.execute();
    }

    public void onClick(View view) {
        this.button.setEnabled(false);
        this.releaseAudioTrack();
        this.audioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, 44100,
                AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT,
                audioData.length, AudioTrack.MODE_STATIC);
        Log.d(TAG, "Writing audio data...");
        this.audioTrack.write(audioData, 0, audioData.length);
        Log.d(TAG, "Starting playback");
        audioTrack.play();
        Log.d(TAG, "Playing");
        this.button.setEnabled(true);
    }

    private void releaseAudioTrack() {
        if (this.audioTrack != null) {
            Log.d(TAG, "Stopping");
            audioTrack.stop();
            Log.d(TAG, "Releasing");
            audioTrack.release();
            Log.d(TAG, "Nulling");
        }
    }

    public void onPause() {
        super.onPause();
        this.releaseAudioTrack();
    }
}
复制代码

MODE_STREAM模式

MODE_STREAM 模式输出音频的方式如下:

byte[] tempBuffer = new byte[bufferSize];
int readCount = 0;
while (dis.available() > 0) {
    readCount = dis.read(tempBuffer);
    if (readCount == AudioTrack.ERROR_INVALID_OPERATION || readCount == AudioTrack.ERROR_BAD_VALUE) {
        continue;
    }
    if (readCount != 0 && readCount != -1) {
        audioTrack.play();
        audioTrack.write(tempBuffer, 0, readCount);
    }
} 
复制代码

AudioTrack 详解

音频流的类型

在AudioTrack构造函数中,会接触到AudioManager.STREAM_MUSIC这个参数。它的含义与Android系统对音频流的管理和分类有关。

Android将系统的声音分为好几种流类型,下面是几个常见的:

· STREAM_ALARM:警告声

· STREAM_MUSIC:音乐声,例如music等

· STREAM_RING:铃声

· STREAM_SYSTEM:系统声音,例如低电提示音,锁屏音等

· STREAM_VOCIE_CALL:通话声

注意:上面这些类型的划分和音频数据本身并没有关系。例如MUSIC和RING类型都可以是某首MP3歌曲。另外,声音流类型的选择没有固定的标准,例如,铃声预览中的铃声可以设置为MUSIC类型。音频流类型的划分和Audio系统对音频的管理策略有关。

Buffer分配和Frame的概念

在计算Buffer分配的大小的时候,我们经常用到的一个方法就是:getMinBufferSize。这个函数决定了应用层分配多大的数据Buffer。

AudioTrack.getMinBufferSize(8000,//每秒8K个采样点                              
        AudioFormat.CHANNEL_CONFIGURATION_STEREO,//双声道                  
        AudioFormat.ENCODING_PCM_16BIT);
复制代码

从AudioTrack.getMinBufferSize开始追溯代码,可以发现在底层的代码中有一个很重要的概念:Frame(帧)。Frame是一个单位,用来描述数据量的多少。1单位的Frame等于1个采样点的字节数×声道数(比如PCM16,双声道的1个Frame等于2×2=4字节)。1个采样点只针对一个声道,而实际上可能会有一或多个声道。由于不能用一个独立的单位来表示全部声道一次采样的数据量,也就引出了Frame的概念。Frame的大小,就是一个采样点的字节数×声道数。另外,在目前的声卡驱动程序中,其内部缓冲区也是采用Frame作为单位来分配和管理的。

下面是追溯到的native层的方法:

 // minBufCount表示缓冲区的最少个数,它以Frame作为单位
   uint32_t minBufCount = afLatency / ((1000 *afFrameCount)/afSamplingRate);
    if(minBufCount < 2) minBufCount = 2;//至少要两个缓冲
 
   //计算最小帧个数
   uint32_tminFrameCount =               
         (afFrameCount*sampleRateInHertz*minBufCount)/afSamplingRate;
  //下面根据最小的FrameCount计算最小的缓冲大小   
   intminBuffSize = minFrameCount //计算方法完全符合我们前面关于Frame的介绍
           * (audioFormat == javaAudioTrackFields.PCM16 ? 2 : 1)
           * nbChannels;
 
    returnminBuffSize;
复制代码

getMinBufSize会综合考虑硬件的情况(诸如是否支持采样率,硬件本身的延迟情况等)后,得出一个最小缓冲区的大小。一般我们分配的缓冲大小会是它的整数倍。

AudioTrack构造过程

每一个音频流对应着一个AudioTrack类的一个实例,每个AudioTrack会在创建时注册到 AudioFlinger中,由AudioFlinger把所有的AudioTrack进行混合(Mixer),然后输送到AudioHardware中进行播放,目前Android同时最多可以创建32个音频流,也就是说,Mixer最多会同时处理32个AudioTrack的数据流。

image.png

AudioTrack 与 MediaPlayer 的对比

播放声音可以用MediaPlayer和AudioTrack,两者都提供了Java API供应用开发者使用。虽然都可以播放声音,但两者还是有很大的区别的。

区别

其中最大的区别是MediaPlayer可以播放多种格式的声音文件,例如MP3,AAC,WAV,OGG,MIDI等。MediaPlayer会在framework层创建对应的音频解码器。而AudioTrack只能播放已经解码的PCM流,如果对比支持的文件格式的话则是AudioTrack只支持wav格式的音频文件,因为wav格式的音频文件大部分都是PCM流。AudioTrack不创建解码器,所以只能播放不需要解码的wav文件。

联系

MediaPlayer在framework层还是会创建AudioTrack,把解码后的PCM数流传递给AudioTrack,AudioTrack再传递给AudioFlinger进行混音,然后才传递给硬件播放,所以是MediaPlayer包含了AudioTrack。

SoundPool

在接触Android音频播放API的时候,发现SoundPool也可以用于播放音频。下面是三者的使用场景:MediaPlayer 更加适合在后台长时间播放本地音乐文件或者在线的流式资源; SoundPool 则适合播放比较短的音频片段,比如游戏声音、按键声、铃声片段等等,它可以同时播放多个音频; 而 AudioTrack 则更接近底层,提供了非常强大的控制能力,支持低延迟播放,适合流媒体和VoIP语音电话等场景。

四丶使用 Camera API 采集视频数据

本文主要将的是:使用 Camera API 采集视频数据并保存到文件,分别使用 SurfaceView、TextureView 来预览 Camera 数据,取到 NV21 的数据回调。

注: 需要权限

预览 Camera 数据

做过Android开发的人一般都知道,有两种方法能够做到这一点:SurfaceView、TextureView。

下面是使用SurfaceView预览数据的方式:

SurfaceView surfaceView;
Camera camera;

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    surfaceView = (SurfaceView) findViewById(R.id.surface_view);
    surfaceView.getHolder().addCallback(this);

    // 打开摄像头并将展示方向旋转90度
    camera = Camera.open();
    camera.setDisplayOrientation(90);

}

//------ Surface 预览 -------
@Override
public void surfaceCreated(SurfaceHolder surfaceHolder) {
    try {
        camera.setPreviewDisplay(surfaceHolder);
        camera.startPreview();
    } catch (IOException e) {
        e.printStackTrace();
    }
}


@Override
public void surfaceChanged(SurfaceHolder surfaceHolder, int format, int w, int h) {

}

@Override
public void surfaceDestroyed(SurfaceHolder surfaceHolder) {
    camera.release();
}
复制代码

下面是使用TextureView预览数据的方式:

    TextureView textureView;
    Camera camera;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        textureView = (TextureView) findViewById(R.id.texture_view);
        textureView.setSurfaceTextureListener(this);// 打开摄像头并将展示方向旋转90度
        camera = Camera.open();
        camera.setDisplayOrientation(90);
    }  //------ Texture 预览 -------
    @Override
    public void onSurfaceTextureAvailable(SurfaceTexture surfaceTexture, int i, int i1) {
        try {
            camera.setPreviewTexture(surfaceTexture);
            camera.startPreview();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void onSurfaceTextureSizeChanged(SurfaceTexture surfaceTexture, int i, int i1) {

    }

    @Override
    public boolean onSurfaceTextureDestroyed(SurfaceTexture surfaceTexture) {
        camera.release();
        return false;
    }

    @Override
    public void onSurfaceTextureUpdated(SurfaceTexture surfaceTexture) {

    }
复制代码

取到 NV21 的数据回调

Android 中Google支持的 Camera Preview Callback的YUV常用格式有两种:一个是NV21,一个是YV12。Android一般默认使用YCbCr_420_SP的格式(NV21)。

我们可以配置数据回调的格式:

Camera.Parameters parameters = camera.getParameters();
parameters.setPreviewFormat(ImageFormat.NV21);
camera.setParameters(parameters);
复制代码

通过setPreviewCallback方法监听预览的回调:

camera.setPreviewCallback(new Camera.PreviewCallback() {
    @Override
    public void onPreviewFrame(byte[] bytes, Camera camera) {

    }
});
复制代码

这里面的Bytes的数据就是NV21格式的数据。

在后面的文章中,会对这些数据进行处理,来满足相关的需求场景。

一个音视频文件是由音频和视频组成的,我们可以通过MediaExtractor、MediaMuxer把音频或视频给单独抽取出来,抽取出来的音频和视频能单独播放;

五丶使用 MediaExtractor 和 MediaMuxer API 解析和封装 mp4 文件

MediaExtractor API介绍

MediaExtractor的作用是把音频和视频的数据进行分离。

主要API介绍:

  • setDataSource(String path):即可以设置本地文件又可以设置网络文件
  • getTrackCount():得到源文件通道数
  • getTrackFormat(int index):获取指定(index)的通道格式
  • getSampleTime():返回当前的时间戳
  • readSampleData(ByteBuffer byteBuf, int offset):把指定通道中的数据按偏移量读取到ByteBuffer中;
  • advance():读取下一帧数据
  • release(): 读取结束后释放资源

使用示例:

 MediaExtractor extractor = new MediaExtractor();
 extractor.setDataSource(...);
 int numTracks = extractor.getTrackCount();
 for (int i = 0; i < numTracks; ++i) {
   MediaFormat format = extractor.getTrackFormat(i);
   String mime = format.getString(MediaFormat.KEY_MIME);
   if (weAreInterestedInThisTrack) {
     extractor.selectTrack(i);
   }
 }
 ByteBuffer inputBuffer = ByteBuffer.allocate(...)
 while (extractor.readSampleData(inputBuffer, ...) >= 0) {
   int trackIndex = extractor.getSampleTrackIndex();
   long presentationTimeUs = extractor.getSampleTime();
   ...
   extractor.advance();
 }

 extractor.release();
 extractor = null;
复制代码

MediaMuxer API介绍

MediaMuxer的作用是生成音频或视频文件;还可以把音频与视频混合成一个音视频文件。

相关API介绍:

  • MediaMuxer(String path, int format):path:输出文件的名称 format:输出文件的格式;当前只支持MP4格式;
  • addTrack(MediaFormat format):添加通道;我们更多的是使用MediaCodec.getOutpurForma()Extractor.getTrackFormat(int index)来获取MediaFormat;也可以自己创建;
  • start():开始合成文件
  • writeSampleData(int trackIndex, ByteBuffer byteBuf, MediaCodec.BufferInfo bufferInfo):把ByteBuffer中的数据写入到在构造器设置的文件中;
  • stop():停止合成文件
  • release():释放资源

使用示例:

MediaMuxer muxer = new MediaMuxer("temp.mp4", OutputFormat.MUXER_OUTPUT_MPEG_4);
 // More often, the MediaFormat will be retrieved from MediaCodec.getOutputFormat()
 // or MediaExtractor.getTrackFormat().
 MediaFormat audioFormat = new MediaFormat(...);
 MediaFormat videoFormat = new MediaFormat(...);
 int audioTrackIndex = muxer.addTrack(audioFormat);
 int videoTrackIndex = muxer.addTrack(videoFormat);
 ByteBuffer inputBuffer = ByteBuffer.allocate(bufferSize);
 boolean finished = false;
 BufferInfo bufferInfo = new BufferInfo();

 muxer.start();
 while(!finished) {
   // getInputBuffer() will fill the inputBuffer with one frame of encoded
   // sample from either MediaCodec or MediaExtractor, set isAudioSample to
   // true when the sample is audio data, set up all the fields of bufferInfo,
   // and return true if there are no more samples.
   finished = getInputBuffer(inputBuffer, isAudioSample, bufferInfo);
   if (!finished) {
     int currentTrackIndex = isAudioSample ? audioTrackIndex : videoTrackIndex;
     muxer.writeSampleData(currentTrackIndex, inputBuffer, bufferInfo);
   }
 };
 muxer.stop();
 muxer.release();
复制代码

使用情境

从MP4文件中提取视频并生成新的视频文件

public class MainActivity extends AppCompatActivity {

    private static final String SDCARD_PATH = Environment.getExternalStorageDirectory().getPath();

    private MediaExtractor mMediaExtractor;
    private MediaMuxer mMediaMuxer;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // 获取权限
        int checkWriteExternalPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE);
        int checkReadExternalPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE);if (checkWriteExternalPermission != PackageManager.PERMISSION_GRANTED ||
                checkReadExternalPermission != PackageManager.PERMISSION_GRANTED) {

            ActivityCompat.requestPermissions(this, new String[]{
                    Manifest.permission.WRITE_EXTERNAL_STORAGE,
                    Manifest.permission.READ_EXTERNAL_STORAGE}, 0);
        }

        setContentView(R.layout.activity_main);
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    process();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }

    private boolean process() throws IOException {
        mMediaExtractor = new MediaExtractor();
        mMediaExtractor.setDataSource(SDCARD_PATH + "/ss.mp4");

        int mVideoTrackIndex = -1;
        int framerate = 0;
        for (int i = 0; i < mMediaExtractor.getTrackCount(); i++) {
            MediaFormat format = mMediaExtractor.getTrackFormat(i);
            String mime = format.getString(MediaFormat.KEY_MIME);
            if (!mime.startsWith("video/")) {
                continue;
            }
            framerate = format.getInteger(MediaFormat.KEY_FRAME_RATE);
            mMediaExtractor.selectTrack(i);
            mMediaMuxer = new MediaMuxer(SDCARD_PATH + "/ouput.mp4", MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
            mVideoTrackIndex = mMediaMuxer.addTrack(format);
            mMediaMuxer.start();
        }

        if (mMediaMuxer == null) {
            return false;
        }

        MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
        info.presentationTimeUs = 0;
        ByteBuffer buffer = ByteBuffer.allocate(500 * 1024);
        int sampleSize = 0;
        while ((sampleSize = mMediaExtractor.readSampleData(buffer, 0)) > 0) {

            info.offset = 0;
            info.size = sampleSize;
            info.flags = MediaCodec.BUFFER_FLAG_SYNC_FRAME;
            info.presentationTimeUs += 1000 * 1000 / framerate;
            mMediaMuxer.writeSampleData(mVideoTrackIndex, buffer, info);
            mMediaExtractor.advance();
        }

        mMediaExtractor.release();

        mMediaMuxer.stop();
        mMediaMuxer.release();

        return true;
    }
}
复制代码

六丶MediaCodec API 详解

在学习了Android 音视频的基本的相关知识,并整理了相关的API之后,我们应该对基本的音视频有一定的轮廓了。

下面开始接触一个Android音视频中相当重要的一个API: **MediaCodec。**通过这个API,我们能够做很多Android音视频方面的工作,下面是我们学习这个API的时候,主要的方向:

  • 学习 MediaCodec API,完成音频 AAC 硬编、硬解
  • 学习 MediaCodec API,完成视频 H.264 的硬编、硬解

MediaCodec 介绍

MediaCodec类可以用于使用一些基本的多媒体编解码器(音视频编解码组件),它是Android基本的多媒体支持基础架构的一部分通常和 MediaExtractor, MediaSync, MediaMuxer, MediaCrypto, MediaDrm, Image, Surface, and AudioTrack 一起使用。

一个编解码器可以处理输入的数据来产生输出的数据,编解码器使用一组输入和输出缓冲器来异步处理数据。你可以创建一个空的输入缓冲区,填充数据后发送到编解码器进行处理。编解码器使用输入的数据进行转换,然后输出到一个空的输出缓冲区。最后你获取到输出缓冲区的数据,消耗掉里面的数据,释放回编解码器。如果后续还有数据需要继续处理,编解码器就会重复这些操作。输出流程如下:

image.png

编解码器支持的数据类型:

编解码器能处理的数据类型为: **压缩数据、原始音频数据和原始视频数据。**你可以通过ByteBuffers能够处理这三种数据,但是需要你提供一个Surface,用于对原始的视频数据进行展示,这样也能提高编解码的性能。Surface使用的是本地的视频缓冲区,这个缓冲区不映射或拷贝到ByteBuffers。这样的机制让编解码器的效率更高。通常在使用Surface的时候,无法访问原始的视频数据,但是你可以使用ImageReader访问解码后的原始视频帧。在使用ByteBuffer的模式下,您可以使用Image类和getInput/OutputImage(int)访问原始视频帧。

编解码器的生命周期:

  主要的生命周期为:Stopped、Executing、Released。

  • Stopped的状态下也分为三种子状态:Uninitialized、Configured、Error。
  • Executing的状态下也分为三种子状态:Flushed, Running、End-of-Stream。

下图是生命周期的说明图:

image.png

如图可以看到:

  1. 当创建编解码器的时候处于未初始化状态。首先你需要调用configure(…)方法让它处于Configured状态,然后调用start()方法让其处于Executing状态。在Executing状态下,你就可以使用上面提到的缓冲区来处理数据。
  2. Executing的状态下也分为三种子状态:Flushed, Running、End-of-Stream。在start() 调用后,编解码器处于Flushed状态,这个状态下它保存着所有的缓冲区。一旦第一个输入buffer出现了,编解码器就会自动运行到Running的状态。当带有end-of-stream标志的buffer进去后,编解码器会进入End-of-Stream状态,这种状态下编解码器不在接受输入buffer,但是仍然在产生输出的buffer。此时你可以调用flush()方法,将编解码器重置于Flushed状态。
  3. 调用stop()将编解码器返回到未初始化状态,然后可以重新配置。 完成使用编解码器后,您必须通过调用release()来释放它。
  4. 在极少数情况下,编解码器可能会遇到错误并转到错误状态。 这是使用来自排队操作的无效返回值或有时通过异常来传达的。 调用reset()使编解码器再次可用。 您可以从任何状态调用它来将编解码器移回未初始化状态。 否则,调用 release()动到终端释放状态。

MediaCodec API 说明

MediaCodec可以处理具体的视频流,主要有这几个方法:

  • getInputBuffers:获取需要编码数据的输入流队列,返回的是一个ByteBuffer数组
  • queueInputBuffer:输入流入队列
  • dequeueInputBuffer:从输入流队列中取数据进行编码操作
  • getOutputBuffers:获取编解码之后的数据输出流队列,返回的是一个ByteBuffer数组
  • dequeueOutputBuffer:从输出队列中取出编码操作之后的数据
  • releaseOutputBuffer:处理完成,释放ByteBuffer数据

MediaCodec 流控

流控基本概念

流控就是流量控制。为什么要控制,因为条件有限! 涉及到了 TCP 和视频编码:

对 TCP 来说就是控制单位时间内发送数据包的数据量,对编码来说就是控制单位时间内输出数据的数据量。

  • TCP 的限制条件是网络带宽,流控就是在避免造成或者加剧网络拥塞的前提下,尽可能利用网络带宽。带宽够、网络好,我们就加快速度发送数据包,出现了延迟增大、丢包之后,就放慢发包的速度(因为继续高速发包,可能会加剧网络拥塞,反而发得更慢)。
  • 视频编码的限制条件最初是解码器的能力,码率太高就会无法解码,后来随着 codec 的发展,解码能力不再是瓶颈,限制条件变成了传输带宽/文件大小,我们希望在控制数据量的前提下,画面质量尽可能高。

一般编码器都可以设置一个目标码率,但编码器的实际输出码率不会完全符合设置,因为在编码过程中实际可以控制的并不是最终输出的码率,而是编码过程中的一个量化参数(Quantization Parameter,QP),它和码率并没有固定的关系,而是取决于图像内容。

无论是要发送的 TCP 数据包,还是要编码的图像,都可能出现“尖峰”,也就是短时间内出现较大的数据量。TCP 面对尖峰,可以选择不为所动(尤其是网络已经拥塞的时候),这没有太大的问题,但如果视频编码也对尖峰不为所动,那图像质量就会大打折扣了。如果有几帧数据量特别大,但仍要把码率控制在原来的水平,那势必要损失更多的信息,因此图像失真就会更严重。

Android 硬编码流控

MediaCodec 流控相关的接口并不多,一是配置时设置目标码率和码率控制模式,二是动态调整目标码率(Android 19 版本以上)。

配置时指定目标码率和码率控制模式:

mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitRate);
mediaFormat.setInteger(MediaFormat.KEY_BITRATE_MODE,
MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR);
mVideoCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
复制代码

码率控制模式有三种:

  • CQ 表示完全不控制码率,尽最大可能保证图像质量;
  • CBR 表示编码器会尽量把输出码率控制为设定值,即我们前面提到的“不为所动”;
  • VBR 表示编码器会根据图像内容的复杂度(实际上是帧间变化量的大小)来动态调整输出码率,图像复杂则码率高,图像简单则码率低;

动态调整目标码率:

Bundle param = new Bundle();
param.putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, bitrate);
mediaCodec.setParameters(param);
复制代码

Android 流控策略选择

  • 质量要求高、不在乎带宽、解码器支持码率剧烈波动的情况下,可以选择 CQ 码率控制策略。
  • VBR 输出码率会在一定范围内波动,对于小幅晃动,方块效应会有所改善,但对剧烈晃动仍无能为力;连续调低码率则会导致码率急剧下降,如果无法接受这个问题,那 VBR 就不是好的选择。
  • CBR 的优点是稳定可控,这样对实时性的保证有帮助。所以 WebRTC 开发中一般使用的是CBR。

七丶音视频录制流程总结

在前面我们学习和使用了AudioRecord、AudioTrack、Camera、 MediaExtractor、MediaMuxer API、MediaCodec。 学习和使用了上述的API之后,相信对Android系统的音视频处理有一定的经验和心得了。本文及后面的几篇文章做的事情就是将这些知识串联起来,做一些稍微复杂的事情。

流程分析

需求说明

我们需要做的事情就是:串联整个音视频录制流程,完成音视频的采集、编码、封包成 mp4 输出。

实现方式

Android音视频采集的方法:预览用SurfaceView,视频采集用Camera类,音频采集用AudioRecord。

数据处理思路

使用MediaCodec 类进行编码压缩,视频压缩为H.264,音频压缩为aac,使用MediaMuxer 将音视频合成为MP4。

实现过程

收集Camera数据,并转码为H264存储到文件

在收集数据之前,对Camera设置一些参数,方便收集后进行数据处理:

Camera.Parameters parameters = camera.getParameters();
parameters.setPreviewFormat(ImageFormat.NV21);
parameters.setPreviewSize(1280, 720);
复制代码

然后设置PreviewCallback:

camera.setPreviewCallback(this);
复制代码

就可以获取到Camera的原始NV21数据:

onPreviewFrame(byte[] bytes, Camera camera)
复制代码

在创建一个H264Encoder类,在里面进行编码操作,并将编码后的数据存储到文件:

new Thread(new Runnable() {

    @Override
    public void run() {
        isRuning = true;
        byte[] input = null;
        long pts = 0;
        long generateIndex = 0;

        while (isRuning) {
            if (yuv420Queue.size() > 0) {
                input = yuv420Queue.poll();
                byte[] yuv420sp = new byte[width * height * 3 / 2];
                // 必须要转格式,否则录制的内容播放出来为绿屏
                NV21ToNV12(input, yuv420sp, width, height);
                input = yuv420sp;
            }
            if (input != null) {
                try {
                    ByteBuffer[] inputBuffers = mediaCodec.getInputBuffers();
                    ByteBuffer[] outputBuffers = mediaCodec.getOutputBuffers();
                    int inputBufferIndex = mediaCodec.dequeueInputBuffer(-1);
                    if (inputBufferIndex >= 0) {
                      pts = computePresentationTime(generateIndex);
                      ByteBuffer inputBuffer = inputBuffers[inputBufferIndex];
                      inputBuffer.clear();
                      inputBuffer.put(input);
                      mediaCodec.queueInputBuffer(inputBufferIndex, 0, input.length, System.currentTimeMillis(), 0);
                      generateIndex += 1;
                    }

                    MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
                    int outputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_USEC);
                    while (outputBufferIndex >= 0) {
                        ByteBuffer outputBuffer = outputBuffers[outputBufferIndex];
                        byte[] outData = new byte[bufferInfo.size];
                        outputBuffer.get(outData);
                        if (bufferInfo.flags == MediaCodec.BUFFER_FLAG_CODEC_CONFIG) {
                            configbyte = new byte[bufferInfo.size];
                            configbyte = outData;
                        } else if (bufferInfo.flags == MediaCodec.BUFFER_FLAG_SYNC_FRAME) {
                            byte[] keyframe = new byte[bufferInfo.size + configbyte.length];
                            System.arraycopy(configbyte, 0, keyframe, 0, configbyte.length);
                            System.arraycopy(outData, 0, keyframe, configbyte.length, outData.length);
                            outputStream.write(keyframe, 0, keyframe.length);
                        } else {
                            outputStream.write(outData, 0, outData.length);
                        }

                        mediaCodec.releaseOutputBuffer(outputBufferIndex, false);
                        outputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_USEC);
                    }

                } catch (Throwable t) {
                    t.printStackTrace();
                }
            } else {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }

        // 停止编解码器并释放资源
        try {
            mediaCodec.stop();
            mediaCodec.release();
        } catch (Exception e) {
            e.printStackTrace();
        }

        // 关闭数据流
        try {
            outputStream.flush();
            outputStream.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}).start(); 
复制代码

当结束编码的时候,需要将相关的资源释放掉:

// 停止编解码器并释放资源
try {
    mediaCodec.stop();
    mediaCodec.release();
} catch (Exception e) {
    e.printStackTrace();
}

// 关闭数据流
try {
    outputStream.flush();
    outputStream.close();
} catch (IOException e) {
    e.printStackTrace();
}
复制代码

此时,我们做到了将视频内容采集-->编码-->存储文件。但这个仅仅是Android 音视频开发(四):使用 Camera API 采集视频数据的延伸,但是很有必要。因为在前面学习了如何采集音频,如何使用MediaCodec去处理音视频,如何使用MediaMuxer去混合音视频。

下面我们在当前的的基础上继续完善,即将音视频采集并混合为音视频。

音视频采集+混合,存储到文件

八丶音视频同步简单介绍

音视频同步在音视频开发是有一定难度的。

本文主要描述音视频同步原理,及常见的音视频同步方案,并以代码示例,展示如何以音频的播放时长 为基准。

将视频同步到音频上以实现视音频的同步播放。内容如下:

1.音视频同步简单介绍
2.DTS和PTS简介 (I/P/B帧;时间戳DTS、PTS)
3.常用同步策略
4.音视频同步简单示例代码

1.音视频同步简单介绍

对于一个播放器,一般来说,其基本构成均可划分为以下几部分:

数据接收(网络/本地)->解复用->音视频解码->音视频同步->音视频输出。

基本框架如下图所示:

image.png 为什么需要音视频同步? 媒体数据经过解复用流程后,音频/视频解码便是独立的,也是独立播放的。而在音频流和视频流中,其 播放速度都是有相关信息指定的:

视频:帧率,表示视频一秒显示的帧数。 音频:采样率,表示音频一秒播放的样本的个数。

image.png

从帧率及采样率,即可知道视频/音频播放速度

声卡和显卡均是以一帧数据来作为播放单位,如果单纯依赖帧率及采样率来进行播放,在理想条件下, 应该是同步的,不会出现偏差。

以一个44.1KHz的AAC音频流和24FPS的视频流为例:

  • 一个AAC音频frame每个声道包含1024个采样点,则一个frame的播放时长(duration)为:(1024/44100)×1000ms = 23.22ms;
  • 一个视频frame播放时长(duration)为:1000ms/24 = 41.67ms。理想情况下,音视频完全同步

音视频播放过程如下图所示

image.png 但实际情况下,如果用上面那种简单的方式,慢慢的就会出现音视频不同步的情况,要不是视频播放快 了,要么是音频播放快了。可能的原因如下:

一帧的播放时间,难以精准控制。音视频解码及渲染的耗时不同,可能造成每一帧输出有一点细微差 距,长久累计,不同步便越来越明显。(例如受限于性能,42ms才能输出一帧)

音频输出是线性的,而视频输出可能是非线性,从而导致有偏差。

媒体流本身音视频有差距。(特别是TS实时流,音视频能播放的第一个帧起点不同)

所以,解决音视频同步问题,引入了时间戳: 首先选择一个参考时钟(要求参考时钟上的时间是线性递增的);编码时依据参考时钟上的给每个音视频数据块都打上时间戳;

播放时,根据音视频时间戳及参考时钟,来调整播放。

所以,视频和音频的同步实际上是一个动态的过程,同步是暂时的,不同步则是常态。以参考时钟为标 准,放快了就减慢播放速度;播放快了就加快播放的速度。

接下来,我们介绍媒体流中时间戳的概念

2.DTS和PTS简介

I/P/B帧

在介绍DTS/PTS之前,我们先了解I/P/B帧的概念。I/P/B帧本身和音视频同步关系不大,但理解其概念有 助于我们了解DTS/PTS存在的意义。

视频本质上是由一帧帧画面组成,但实际应用过程中,每一帧画面会进行压缩(编码)处理,已达到减 少空间占用的目的。

编码方式可以分为帧内编码和帧间编码。

内编码方式: 即只利用了单帧图像内的空间相关性,对冗余数据进行编码,达到压缩效果,而没有利用时间相关性, 不使用运动补偿。所以单靠自己,便能完整解码出一帧画面。

帧间编码

由于视频的特性,相邻的帧之间其实是很相似的,通常是运动矢量的变化。利用其时间相关性,可以通 过参考帧运动矢量的变化来预测图像,并结合预测图像与原始图像的差分,便能解码出原始图像。所 以,帧间编码需要依赖其他帧才能解码出一帧画面。 由于编码方式的不同,视频中的画面帧就分为了不同的类别,其中包括:I 帧、P 帧、B 帧。I 帧、P 帧、B 帧的区别在于:

  • I 帧(Intra coded frames):

I 帧图像采用帧I 帧使用帧内压缩,不使用运动补偿,由于 I 帧不依赖其它帧,可以独立解码。I 帧图像的 压缩倍数相对较低,周期性出现在图像序列中的,出现频率可由编码器选择。

  • P 帧(Predicted frames):

P 帧采用帧间编码方式,即同时利用了空间和时间上的相关性。P 帧图像只采用前向时间预测,可以提 高压缩效率和图像质量。P 帧图像中可以包含帧内编码的部分,即 P 帧中的每一个宏块可以是前向预 测,也可以是帧内编码。

  • B 帧(Bi-directional predicted frames):

B 帧图像采用帧间编码方式,且采用双向时间预测,可以大大提高压缩倍数。也就是其在时间相关性 上,还依赖后面的视频帧,也正是由于 B 帧图像采用了后面的帧作为参考,因此造成视频帧的传输顺序 和显示顺序是不同的。

也就是说,一个 I 帧可以不依赖其他帧就解码出一幅完整的图像,而 P 帧、B 帧不行。P 帧需要依赖视 频流中排在它前面的帧才能解码出图像。B 帧则需要依赖视频流中排在它前面或后面的I/P帧才能解码出 图像。

对于I帧和P帧,其解码顺序和显示顺序是相同的,但B帧不是,如果视频流中存在B帧,那么就会打算解 码和显示顺序。

正因为解码和显示的这种非线性关系,所以需要DTS、PTS来标识帧的解码及显示时间。

时间戳DTS、PTS

DTS(Decoding Time Stamp):即解码时间戳,这个时间戳的意义在于告诉播放器该在什么时候解码 这一帧的数据。

PTS(Presentation Time Stamp):即显示时间戳,这个时间戳用来告诉播放器该在什么时候显示这一 帧的数据。

当视频流中没有 B 帧时,通常 DTS 和 PTS 的顺序是一致的。但如果有 B 帧时,就回到了我们前面说的 问题:解码顺序和播放顺序不一致了,即视频输出是非线性的。

比如一个视频中,帧的显示顺序是:I B B P,因为B帧解码需要依赖P帧,因此这几帧在视频流中的顺序 可能是:I P B B,这时候就体现出每帧都有 DTS 和 PTS 的作用了。DTS 告诉我们该按什么顺序解码这 几帧图像,PTS 告诉我们该按什么顺序显示这几帧图像。顺序大概如下:

从流分析工具看,流中P帧在B帧之前,但显示确实在B帧之后。

需要注意的是:虽然 DTS、PTS 是用于指导播放端的行为,但它们是在编码的时候由编码器生成的。 以我们最常见的TS为例: TS流中,PTS/DTS信息在打流阶段生成在PES层,主要是在PES头信息里。

标志第一位是PTS标识,第二位是DTS标识。

标志: 00,表示无PTS无DTS; 01,错误,不能只有DTS没有PTS; 10,有PTS; 11,有PTS和DTS。

PTS有33位,但是它不是直接的33位数据,而是占了5个字节,PTS分别在这5字节中取。

TS的I/P帧携带PTS/DTS信息,B帧PTS/DTS相等,进保留PTS;由于声音没有用到双向预测,它的解码次序就是它的显示次序,故它只有PTS。

TS的编码器中有一个系统时钟STC(其频率是27MHz),此时钟用来产生指示音视频的正确显示和解码

时间戳。

PTS域在PES中为33bits,是对系统时钟的300分频的时钟的计数值。它被编码成为3个独立的字段: PTS[32…30][29…15][14…0]。

DTS域在PES中为33bits,是对系统时钟的300分频的时钟的计数值。它被编码成为3个独立的字段: DTS[32…30][29…15][14…0]。

因此,对于TS流,PTS/DTS时间基均为1/90000秒(27MHz经过300分频)。

PTS对于TS流的意义不仅在于音视频同步,TS流本身不携带duration(可播放时长)信息,所以计算 duration也是根据PTS得到。

附上TS流解析PTS示例:

#define MAKE_WORD(h, l) (((h) << 8) | (l))
  //packet为PES
  int64_t get_pts(const uint8_t *packet) {
     const uint8_t *p = packet;
     if(packet == NULL) {
     return -1;
 }
复制代码
if(!(p[0] == 0x00 && p[1] == 0x00 && p[2] == 0x01)) { 
    //pes sync word
    return -1;
}
p += 3; //jump pes sync word
p += 4; //jump stream id(1) pes length(2) pes flag(1)

int pts_pts_flag = *p >> 6;
p += 2; //jump pes flag(1) pes header length(1)
if (pts_pts_flag & 0x02) {
    int64_t pts32_30, pts29_15, pts14_0, pts;
    pts32_30 = (*p) >> 1 & 0x07;
    p += 1;
    pts29_15 = (MAKE_WORD(p[0],p[1])) >> 1;
    p += 2;
    pts14_0 = (MAKE_WORD(p[0],p[1])) >> 1;
    p += 2;
    pts = (pts32_30 << 30) | (pts29_15 << 15) | pts14_0;
  
    return pts;
    }
return -1;
}
复制代码

常用同步策略 前面已经说了,实现音视频同步,在播放时,需要选定一个参考时钟,读取帧上的时间戳,同时根据的 参考时钟来动态调节播放。现在已经知道时间戳就是PTS,那么参考时钟的选择一般来说有以下三种:

  • 将视频同步到音频上:就是以音频的播放速度为基准来同步视频。
  • 将音频同步到视频上:就是以视频的播放速度为基准来同步音频。
  • 将视频和音频同步外部的时钟上:选择一个外部时钟为基准,视频和音频的播放速度都以该时钟为标 准。

当播放源比参考时钟慢,则加快其播放速度,或者丢弃;快了,则延迟播放。

这三种是最基本的策略,考虑到人对声音的敏感度要强于视频,频繁调节音频会带来较差的观感体验, 且音频的播放时钟为线性增长,所以一般会以音频时钟为参考时钟,视频同步到音频上。

在实际使用基于这三种策略做一些优化调整,例如: 调整策略可以尽量采用渐进的方式,因为音视频同步是一个动态调节的过程,一次调整让音视频PTS完 全一致,没有必要,且可能导致播放异常较为明显。

调整策略仅仅对早到的或晚到的数据块进行延迟或加快处理,有时候是不够的。如果想要更加主动并且 有效地调节播放性能,需要引入一个反馈机制,也就是要将当前数据流速度太快或太慢的状态反馈给 “源”,让源去放慢或加快数据流的速度。

对于起播阶段,特别是TS实时流,由于视频解码需要依赖第一个I帧,而音频是可以实时输出,可能出现 的情况是视频PTS超前音频PTS较多,这种情况下进行同步,势必造成较为明显的慢同步。处理这种情况 的较好方法是将较为多余的音频数据丢弃,尽量减少起播阶段的音视频差距。

音视频同步简单示例代码

代码参考ffplay实现方式,同时加入自己的修改。以audio为参考时钟,video同步到音频的示例代码: 获取当前要显示的video PTS,减去上一帧视频PTS,则得出上一帧视频应该显示的时长delay; 当前video PTS与参考时钟当前audio PTS比较,得出音视频差距diff;获取同步阈值sync_threshold,为一帧视频差距,范围为10ms-100ms; diff小于sync_threshold,则认为不需要同步;否则delay+diff值,则是正确纠正delay;

如果超过sync_threshold,且视频落后于音频,那么需要减小delay(FFMAX(0, delay + diff)),让当前 帧尽快显示。

如果视频落后超过1秒,且之前10次都快速输出视频帧,那么需要反馈给音频源减慢,同时反馈视频源 进行丢帧处理,让视频尽快追上音频。因为这很可能是视频解码跟不上了,再怎么调整delay也没用。

如果超过sync_threshold,且视频快于音频,那么需要加大delay,让当前帧延迟显示。

将delay*2慢慢调整差距,这是为了平缓调整差距,因为直接delay+diff,会让画面画面迟滞。

如果视频前一帧本身显示时间很长,那么直接delay+diff一步调整到位,因为这种情况再慢慢调整也没太大意义。

考虑到渲染的耗时,还需进行调整。frame_timer为一帧显示的系统时间,frame_timer+delay- curr_time,则得出正在需要延迟显示当前帧的时间。

video->frameq.deQueue(&video->frame);
   //获取上一帧需要显示的时长delay
   double current_pts = *(double *)video->frame->opaque;
   double delay = current_pts - video->frame_last_pts;
      if (delay <= 0 || delay >= 1.0) {
     delay = video->frame_last_delay;
   }
}
复制代码
  // 根据视频PTS和参考时钟调整delay
  double ref_clock = audio->get_audio_clock();
  double diff = current_pts - ref_clock;// diff < 0 :video slow,diff > 0 :video fast
  //一帧视频时间或10ms,10ms音视频差距无法察觉
  double sync_threshold = FFMAX(MIN_SYNC_THRESHOLD, FFMIN(MAX_SYNC_THRESHOLD,delay)) ;

  audio->audio_wait_video(current_pts,false);
  video->video_drop_frame(ref_clock,false);
  if (!isnan(diff) && fabs(diff) < NOSYNC_THRESHOLD) // 不同步 {
      if (diff <= -sync_threshold)//视频比音频慢,加快 {
          delay = FFMAX(0, delay + diff);
          static int last_delay_zero_counts = 0;
          if(video->frame_last_delay <= 0) {
             last_delay_zero_counts++;
          } else {
             last_delay_zero_counts = 0;
          }
          if(diff < -1.0 && last_delay_zero_counts >= 10) {
             printf("maybe video codec too slow, adjust video&audio\n"); 
             #ifndef DORP_PACK
             audio->audio_wait_video(current_pts,true);//差距较大,需要反馈音频等待视频
             #endif
             video->video_drop_frame(ref_clock,true);//差距较大,需要视频丢帧追上
          }
      }
      //视频比音频快,减慢
      else if (diff >= sync_threshold && delay > SYNC_FRAMEDUP_THRESHOLD)
          delay = delay + diff;//音视频差距较大,且一帧的超过帧最常时间,一步到位
      else if (diff >= sync_threshold)
          delay = 2 * delay;//音视频差距较小,加倍延迟,逐渐缩小
  }

  video->frame_last_delay = delay;
  video->frame_last_pts = current_pts;

  double curr_time = static_cast<double>(av_gettime()) / 1000000.0;
  if(video->frame_timer == 0)  {
      video->frame_timer = curr_time;//show first frame ,set frame timer
  }

  double actual_delay = video->frame_timer + delay - curr_time;
  if (actual_delay <= MIN_REFRSH_S) {
      actual_delay = MIN_REFRSH_S;
  }
  usleep(static_cast<int>(actual_delay * 1000 * 1000));
  //printf("actual_delay[%lf] delay[%lf] diff[%lf]\n",actual_delay,delay,diff);
  // Display
  SDL_UpdateTexture(video->texture, &(video>rect), video->frame->data[0], video->frame->linesize[0]);
  SDL_RenderClear(video->renderer);
  SDL_RenderCopy(video->renderer, video->texture, &video->rect, &video->rect);
  SDL_RenderPresent(video->renderer);
  video->frame_timer = static_cast<double>(av_gettime()) / 1000000.0 ;

  av_frame_unref(video->frame);

  //update next frame
  schedule_refresh(media, 1);
  }
复制代码

九丶视频变速

1 SoundTouch详解

是一个用C++编写的开源的音频处理库,可以改变音频文件或实时音频流的节拍(Tempo)、音调(Pitch)、 回放率(Playback Rates),还支持估算音轨的稳定节拍率(BPM rate)。ST的3个效果互相独立,也可以一 起使用。这些效果通过采样率转换、时间拉伸结合实现。

  • Tempo节拍 :通过拉伸时间,改变声音的播放速率而不影响音调。
  • Playback Rate回放率 : 以不同的转率播放唱片(DJ打碟?),通过采样率转换实现。
  • Pitch音调 :在保持节拍不变的前提下改变声音的音调,结合采样率转换+时间拉伸实现。如:增高音调的处理过程是:将原音频拉伸时长,再通过采样率转换,同时减少时长与增高音调变为原时长。

2 处理对象

ST处理的对象是PCM(Pulse Code Modulation,脉冲编码调制),.wav文件中主要是这种格式,因此ST的示例都是处理wav音频。mp3等格式经过了压缩,需转换为PCM后再用ST处理。

3.主要特性

  • 易于实现:ST为所有支持gcc编译器或者visual Studio的处理器或操作系统进行了编译,支持Windows、Mac OS、Linux、Android、Apple iOS等。
  • 完全开源:ST库与示例工程完全开源可下载
  • 容易使用:编程接口使用单一的C++类
  • 支持16位整型或32位浮点型的单声道、立体声、多通道的音频格式
  • 可实现实时音频流处理:
    • 输入/输出延迟约为100ms
    • 实时处理44.1kHz/16bit的立体声,需要133Mhz英特尔奔腾处理器或更好

4.相关

官网提供了ST的可执行程序、C++源码、说明文档、不同操作系统的示例工程,几个重要链接:

  • SoundTouch官网
  • ST处理效果预览(SoundStretch是官网用ST库实现的处理WAV音频的工具)
  • 源码编译方法、算法以及参数说明
  • 常见问题(如实时处理)

5 Android中如何使用SoundTouch

Android中使用ST,需将ST的C++代码使用NDK编译为.so库,再通过JNI调用。参考:SoundTouch in Android

6.下载源码

下载:soundtouch-1.9.2.zip ,包含ST的C++源码、Android-lib示例工程。

image.png copy头文件 和 库文件 直接用AndroidStudio编译

7.调用接口与参数

示例工程中的SoundTouch.cpp是ST的调用接口,音调、音速的变化是通过为ST设置新的参数,这些参 数需在正式开始处理前设置好。接口的调用示例可以参考soundtouch-jni.cpp中的_processFile函数。

采样

  • setChannels(int) 设置声道,1 = mono单声道, 2 = stereo立体声
  • setSampleRate(uint) 设置采样率

速率

  • setRate(double) 指定播放速率,原始值为1.0,大快小慢 setTempo(double) 指定节拍,原始值为1.0,大快小慢
  • setRateChange(double) 、
  • setTempoChange(double) 在原速1.0基础上,按百分比做增量,取值(-50 .. +100 %)

音调

  • setPitch(double) 指定音调值,原始值为1.0
  • setPitchOctaves(double) 在原音调基础上以八度音为单位进行调整,取值为[-1.00,+1.00]
  • setPitchSemiTones(int) 在原音调基础上以半音为单位进行调整,取值为[-12,+12]

以上调音函数根据乐理进行单位换算,最后进入相同的处理流程calcEffectiveRateAndTempo()。三个函 数对参数没有上下界限限制,只是参数过大失真越大。SemiTone指半音,通常说的“降1个key”就是降低 1个半音。所以我认为使用SemiTone为单位即可满足需求,并且容易理解。

处理

  • putSamples(const SAMPLETYPE *samples, uint nSamples) 输入采样数据
  • receiveSamples(SAMPLETYPE *output, uint maxSamples) 输出处理后的数据,需要循环执 行
  • flush() 冲出处理管道中的最后一组“残留”的数据,应在最后执行

8.SoundTouch实时处理音频流

ST对音频的处理是输入函数putSamples()与输出函数receiveSamples()。实时处理音频流的思路就是, 循环读取音频数据段,放入ST进行输出,输出处理后的数据段用于播放。

由于业务要求使用Android的AudioEffect机制实现变调处理,得空后再尝试以JNI形式直接处理音频数据 的工程。

新增:Flutter番外篇:Flutter面试-项目实战-电子书;openGL ES深入版+Recyclerview

关注公众号:Android苦做舟
解锁 《Android十一大板块文档》
音视频大合集,从初中高到面试应有尽有;让学习更贴近未来实战。已形成PDF版

十一个模块内容如下

1.2022最新Android11位大厂面试专题,128道附答案
2.音视频大合集,从初中高到面试应有尽有
3.Android车载应用大合集,从零开始一起学
4.性能优化大合集,告别优化烦恼
5.Framework大合集,从里到外分析的明明白白
6.Flutter大合集,进阶Flutter高级工程师
7.compose大合集,拥抱新技术
8.Jetpack大合集,全家桶一次吃个够
9.架构大合集,轻松应对工作需求
10.Android基础篇大合集,根基稳固高楼平地起
11.Flutter番外篇:Flutter面试+项目实战+电子书

整理不易,关注一下吧。ღ( ´・ᴗ・` ) 🤔

收藏成功!
已添加到「」, 点击更改